From 7804ea8f693a45c8cacb039d958be2b26b0a1388 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:35:26 -0400 Subject: [PATCH] feat(update): stable/beta update channel; stable follows release tags (#684) Adds auto_update.channel: stable follows the newest vX.Y.Z release tag (detached HEAD; pre-releases and other tags ignored), beta follows main as before. Nothing ever moves a device backwards: a checkout newer than the newest release keeps following main (or stays put when detached) until a release contains its commit. Legacy configs migrate to stable when they reach a release. Update Code, the weekly updater's preflight, and the verifier's rollback (back to old_ref: branch or detached release) all honour the channel. General tab Update Channel select, GET/POST /api/v3/system/update-channel, release-aware Overview banner and Tools git panel. New installs default to stable. Rig fix (ledpi): /system/check-update reports update_available: false when the channel's action is none (a detached HEAD newer than the newest release), matching Update Code; the Tools panel no longer calls every detached HEAD "a release". Merged with main through #687 (heartbeat verifier, #683 login, #688 plugin_catalog, #685 Tailwind build). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 24 + config/config.template.json | 3 +- docs/ARCHITECTURE.md | 19 +- docs/CONFIG_REFERENCE.md | 1 + docs/GETTING_STARTED.md | 16 + docs/REST_API_REFERENCE.md | 49 +- docs/TROUBLESHOOTING.md | 41 ++ docs/WEB_INTERFACE_GUIDE.md | 9 +- first_time_install.sh | 4 + scripts/utils/auto_update_verify.py | 15 + test/fixtures/api_v3_url_map.json | 17 + test/test_update_channel.py | 693 ++++++++++++++++++ test/test_web_auth.py | 16 + web_interface/auto_update.py | 92 ++- web_interface/blueprints/api_v3/config.py | 16 +- web_interface/blueprints/api_v3/system.py | 229 +++++- web_interface/blueprints/pages_v3.py | 8 + web_interface/templates/v3/base.html | 7 +- .../templates/v3/partials/general.html | 16 + .../templates/v3/partials/tools.html | 11 +- web_interface/update_channel.py | 263 +++++++ 21 files changed, 1499 insertions(+), 50 deletions(-) create mode 100644 test/test_update_channel.py create mode 100644 web_interface/update_channel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index baad4af8..6271e318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,30 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Update channels + +- Devices no longer pick up every merge to `main`. A new setting, + `auto_update.channel`, picks what Update Code and the weekly automatic + update install: `stable` follows the newest release tag (`vX.Y.Z` by + semantic version; pre-releases and other tags are ignored) and checks it + out with a detached HEAD, and `beta` follows `main` as every device did + before. New installs default to `stable` (config template and installer). +- Nobody is moved backwards. A device running code newer than the newest + release, which is any device that pulled `main` since that release, keeps + following `main` until a release contains its commit, then moves to it and + follows releases. A config written before channels existed behaves the + same way and is saved as `stable` when that move happens. Switching from + beta to stable says so instead of installing an older version. +- Switch channels on the General tab (Update Channel, under Automatic + Updates) or with `GET`/`POST /api/v3/system/update-channel`. The Overview + update banner compares release tags on stable ("LEDMatrix v3.8.0 is + available") rather than commits on `main`. A detached checkout newer + than the newest release gets no banner: Update Code leaves it where it + is until a release includes it. +- A move between `main` and a release tag carries local edits across as the + pull's `--autostash` does, and the automatic update's health check rolls + it back to where HEAD was: the branch, or the detached release. + ### Frozen-panel detection A render loop stuck inside a plugin's `display()` left `ledmatrix.service` diff --git a/config/config.template.json b/config/config.template.json index 96a0c589..9f907428 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -1,7 +1,8 @@ { "web_display_autostart": true, "auto_update": { - "enabled": false + "enabled": false, + "channel": "stable" }, "schedule": { "enabled": false, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 323300ff..304ba49c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -336,8 +336,19 @@ everything else through `_reinstall_with_rollback()`. - **Update Code** on the Overview tab and the automatic updater both call `perform_core_update()` in [`api_v3/system.py`](../web_interface/blueprints/api_v3/system.py): - `git pull --rebase`, reinstall changed requirement files, report whether a - restart is needed. + fetch branches and tags, move the checkout for the update channel, reinstall + changed requirement files, report whether a restart is needed. +- **Update channels** (`auto_update.channel`): + [`web_interface/update_channel.py`](../web_interface/update_channel.py) + decides the move. `stable` checks out the newest `vX.Y.Z` tag (detached + HEAD) when it contains the current commit; `beta` is + `git pull --rebase --autostash` on the current branch, and leaves a + detached release for `main` first. A stable device newer than the newest + release keeps pulling `main` until a release contains its commit, so no + update ever moves backwards; a config without the key is written as + `stable` once the device reaches a release. Checkouts carry uncommitted + edits across with `git stash create`/`apply`, and keep them in the stash + list if they no longer apply. - **Automatic updates** (`auto_update.enabled`, off by default): `AutoUpdater` in [`web_interface/auto_update.py`](../web_interface/auto_update.py) runs in the web process, checks every 30 minutes, and updates at most @@ -350,7 +361,9 @@ everything else through `_reinstall_with_rollback()`. both services, waits for the web API to answer and the display service to stay up -- and, when the display wrote a heartbeat before the update, to keep one fresh from the restarted process (see Liveness) -- and on failure - resets to the previous commit and restarts again. + returns to where HEAD was (the branch, or detached on the previous + release; `old_ref` in the pending file), resets to the previous commit + and restarts again. Plugin updates run only after a verified core update. State is in `data/auto_update_state.json` and `data/auto_update_pending.json`. - **Startup validator.** `StartupValidator` diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 7ca6c277..ef2358f2 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -17,6 +17,7 @@ tooling against it. |---|---|---|---| | `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` | | `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) | +| `auto_update.channel` | `"stable"` or `"beta"`, `"stable"` (template) | What Update Code and the weekly update install. `stable`: the newest `vX.Y.Z` release tag (pre-releases ignored), checked out with a detached HEAD. `beta`: `main`. Never moves a device backwards: one newer than the newest release keeps following `main` until a release contains its commit. Missing (configs from before channels) behaves like `stable` and is saved as `stable` once the device is on a release. General tab, Update Channel | `web_interface/update_channel.py` (`resolve()`) | | `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` | | `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` | | `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. Starlark (Tidbyt) apps get the same treatment: a `Location` field left blank on the app renders at this city (geocoded once via Open-Meteo, coordinates cached permanently) instead of the app author's default, which is usually San Francisco. If the city can't be looked up (no match, or the geocoder is unreachable; retried after 30 minutes), the app keeps its own default. | `SchemaManager.apply_device_location()`, then plugins via merged config; `src/device_location.py` for Starlark apps | diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d4c38915..290af715 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -240,6 +240,22 @@ The fastest way to verify a plugin works without waiting for the rotation: - Install community plugins straight from a GitHub URL via **Install from GitHub** on the same tab. +### Keep LEDMatrix Up to Date + +- **Update Code** on the **Overview** tab installs the newest version, and a + banner at the top of the page says when one is available. +- **General → Automatic Updates** does it once a week, overnight, with a + health check that undoes an update that breaks the device. +- **General → Update Channel** picks which version that is. **Stable** (the + default) installs releases, which have been tested and have release + notes. **Beta** installs the newest code as soon as it is written, before + it is released: fixes arrive sooner, and so do new problems. +- Switching to Stable never installs an older version than the one you + have. If your device is already newer than the latest release (which is + normal if it was set up or updated from the newest code), it keeps + getting the newest code until the next release includes it, then follows + releases from there. The General tab says when this is the case. + ### Enable Advanced Features **Vegas Scroll Mode:** diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 6248cb43..b9206825 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -1485,20 +1485,59 @@ Get LEDMatrix repository version. **GET** `/api/v3/system/check-update` -Whether `origin/main` has commits the checkout lacks. Cached briefly. -Fields at the top level (no envelope): +Whether newer code is available on this device's update channel. On +`stable` that is a newer release tag than the checkout (`target_version` +names it); on `beta`, and on `stable` while it waits on a branch for a +release that contains the current commit, it is commits on `origin/main` +the checkout lacks. A detached checkout newer than the newest release is +never offered an update: Update Code leaves it where it is until a release +includes it, and `channel_message` says so in the General tab's words. +Cached briefly. Fields at the top level (no envelope): ```json { "update_available": true, "remote_sha": "abc123...", - "commits_behind": 3 + "commits_behind": 3, + "target_version": "v3.8.0", + "channel": "stable", + "configured_channel": "stable", + "waiting": false, + "newest_release": "v3.8.0", + "current_release": null, + "channel_message": "Stable: release v3.8.0 is available." } ``` When git cannot run the check, the response also carries `"check_failed": true` and an `error` explaining why. +### Update Channel + +**GET** `/api/v3/system/update-channel` + +The update channel and what the next Update Code or weekly update would do +(in `data`): `configured` (`"stable"`, `"beta"` or `null` for a config from +before channels), `channel` (the one in effect), `waiting` (stable, but the +device is newer than the newest release, so it follows `main` for now), +`action` (`none`, `checkout_tag`, `pull` or `switch_to_beta`), +`newest_release`, `current_release`, `branch` (`""` when on a release tag), +`message`. Reads local refs; `?fetch=1` fetches from origin first. + +**POST** `/api/v3/system/update-channel` + +```json +{ + "channel": "beta" +} +``` + +Saves `auto_update.channel`. The next update applies it; switching to +`stable` never installs an older version than the one running, and the +`message` says when the device keeps following `main` until a newer release. +400 for anything but `stable` or `beta`. The General tab form also accepts +`auto_update_channel` on `POST /api/v3/config/main`. + ### Automatic Update Status **GET** `/api/v3/system/auto-update` @@ -1524,7 +1563,9 @@ Hide the current automatic-update alert until a new one replaces it. Branch, dirty state, recent commits and remote for the Tools tab. Fields at the top level: `branch`, `dirty`, `status`, `recent_commits`, `remote_url` -(credentials scrubbed), `upstream`, `can_pull`. +(credentials scrubbed), `upstream`, `can_pull`, and for the update channel +`detached`, `version` (`git describe`), `current_release` (the release tag +HEAD is exactly on, else `null`) and `channel_message` (detached only). ### Git Branches diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e93b1859..e7b41b24 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -295,6 +295,42 @@ sudo systemctl cat ledmatrix-web | grep User --- +#### Issue: Updates and the update channel + +**Symptoms:** +- The General tab says "Stable: this device runs code newer than the newest + release ... keeps following main" +- Tools shows a version such as `v3.8.0` instead of a branch name, or `git + status` over SSH says `HEAD detached at v3.8.0` +- Update Code says "already up to date" while GitHub's `main` has newer commits + +**Explanation:** these are the Stable update channel working as intended +(`auto_update.channel`, General → Update Channel). Stable installs the +newest release tag, which git checks out without a branch ("detached +HEAD"); that is normal and every update path handles it. Stable never +installs an older version than the one running, so a device that is ahead of +the newest release keeps following `main` until a release includes its +commit, then switches to releases on its own. + +**Solutions:** + +1. **Want the newest code instead?** Set Update Channel to **Beta** and click + Update Code. The device leaves the release for `main` and pulls it. + Or from SSH: + ```bash + curl -X POST http://localhost:5000/api/v3/system/update-channel \ + -H 'Content-Type: application/json' -d '{"channel": "beta"}' + ``` +2. **See what the next update will do:** + ```bash + curl 'http://localhost:5000/api/v3/system/update-channel?fetch=1' + ``` +3. **Local changes after a channel switch:** edits that no longer fit the new + version are kept in the git stash rather than lost; `git stash list` + shows them as "LEDMatrix autostash before update". + +--- + ### WiFi & AP Mode Issues #### AP Mode Not Activating @@ -1010,6 +1046,11 @@ git reset --hard HEAD~1 # Or rollback to specific commit git reset --hard +# On the Stable update channel HEAD is a release tag, not a branch: +# go back to an earlier release instead (the next update moves forward again) +git tag --list 'v*' --sort=-v:refname | head +git checkout --detach v3.7.0 + # Restart all services sudo systemctl restart ledmatrix sudo systemctl restart ledmatrix-web diff --git a/docs/WEB_INTERFACE_GUIDE.md b/docs/WEB_INTERFACE_GUIDE.md index 140403fc..973538d3 100644 --- a/docs/WEB_INTERFACE_GUIDE.md +++ b/docs/WEB_INTERFACE_GUIDE.md @@ -78,7 +78,9 @@ The Overview tab provides at-a-glance information and quick actions: - **Start Display** / **Stop Display** — control the display service - **Restart Display Service** — apply configuration changes - **Restart Web Service** — restart the web UI itself -- **Update Code** — `git pull` the latest version (stashes local changes) +- **Update Code** — update to the newest version on the update channel (the + newest release on Stable, the newest code on `main` on Beta; stashes local + changes). The channel is set on the General tab. - **Reboot System** / **Shutdown System** — confirm-gated power controls **Display Preview:** @@ -90,6 +92,11 @@ The Overview tab provides at-a-glance information and quick actions: Configure basic system settings: +- **Automatic Updates** — weekly updates with a health check and rollback +- **Update Channel** — **Stable** (default) installs releases; **Beta** + installs the newest code on `main` before it is released. Switching to + Stable never installs an older version: a device ahead of the newest + release keeps following `main` until a release includes it - **Timezone** — used by all time/date displays - **Location** — city/state/country for weather and other location-aware plugins diff --git a/first_time_install.sh b/first_time_install.sh index 8d67175a..c6ced953 100755 --- a/first_time_install.sh +++ b/first_time_install.sh @@ -835,6 +835,10 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config.json" ]; then cat > "$PROJECT_ROOT_DIR/config/config.json" <<'EOF' { "web_display_autostart": true, + "auto_update": { + "enabled": false, + "channel": "stable" + }, "timezone": "America/Chicago", "display": { "hardware": { diff --git a/scripts/utils/auto_update_verify.py b/scripts/utils/auto_update_verify.py index 37097c42..0d07c45a 100644 --- a/scripts/utils/auto_update_verify.py +++ b/scripts/utils/auto_update_verify.py @@ -14,6 +14,7 @@ the same reason: the rollback cannot depend on packages the update changed. 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": ...} This moves its status to "verifying" and then to one of "success", @@ -276,6 +277,20 @@ class Verifier: if not old: return False, 'the commit to roll back to is unknown' requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES) + # An update may have moved HEAD between main and a detached release + # tag (the stable/beta channels). Go back to where HEAD was -- the + # branch, or detached -- before resetting, or resetting would drag + # the wrong ref: main onto a release commit, or leave a device that + # was following main stuck on a detached one. No old_ref (an older + # updater wrote this file) means HEAD never moved between refs. + old_ref = pending.get('old_ref') + if old_ref is not None: + move = (['git', 'checkout', '--quiet', '--force', old_ref] if old_ref + else ['git', 'checkout', '--quiet', '--force', '--detach', old]) + result = self._run(move, timeout=GIT_RESET_TIMEOUT_SECONDS) + if result.returncode != 0: + return False, (f'"{" ".join(move)}" failed: ' + f'{(result.stderr or result.stdout or "").strip()}') # --hard: the updater refuses to run with local edits to tracked core # files (web_interface/auto_update.local_changes), so outside the # plugin folders the only thing this discards is the update. Edits diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index 83a5d66d..1e5a3779 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -903,6 +903,23 @@ "OPTIONS" ] ], + [ + "/api/v3/system/update-channel", + "api_v3.get_update_channel", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/update-channel", + "api_v3.set_update_channel", + [ + "OPTIONS", + "POST" + ] + ], [ "/api/v3/system/version", "api_v3.get_system_version", diff --git a/test/test_update_channel.py b/test/test_update_channel.py new file mode 100644 index 00000000..8011aed9 --- /dev/null +++ b/test/test_update_channel.py @@ -0,0 +1,693 @@ +"""Stable and beta update channels (web_interface/update_channel.py). + +stable follows the newest vX.Y.Z release tag; beta follows main, as every +device did before channels existed. What these pin down: + +* which tags count as releases (not pre-releases, not anything else); +* which channel is in effect, including configs written before channels + existed, which follow main until the newest release contains their commit + and then move to stable and say so in config.json; +* that nothing ever moves a device to an older commit than the one it runs; +* that the real update paths (Update Code, the weekly updater and its + rollback) handle a detached release checkout and switching back and forth. + +Git runs for real against a throwaway origin and device clone. +""" +import importlib.util +import shutil +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from web_interface import update_channel as uc # noqa: E402 +from web_interface import auto_update as au # noqa: E402 + +needs_git = pytest.mark.skipif(shutil.which('git') is None, reason='git not installed') + +_spec = importlib.util.spec_from_file_location( + 'auto_update_verify_for_channels', ROOT / 'scripts' / 'utils' / 'auto_update_verify.py') +av = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(av) + + +def git(cwd, *args): + result = subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com', *args], + cwd=str(cwd), capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +class Repo: + """An origin, a checkout that publishes to it, and the device's clone.""" + + def __init__(self, tmp): + self.seed = tmp / 'seed' + self.seed.mkdir() + git(self.seed, 'init', '-q', '-b', 'main') + (self.seed / 'app.py').write_text('v = 1\n') + (self.seed / 'notes.txt').write_text('notes\n') + git(self.seed, 'add', '.') + git(self.seed, 'commit', '-qm', 'one') + self.origin = tmp / 'origin.git' + git(tmp, 'clone', '-q', '--bare', str(self.seed), str(self.origin)) + git(self.seed, 'remote', 'add', 'origin', str(self.origin)) + self.device = tmp / 'device' + git(tmp, 'clone', '-q', str(self.origin), str(self.device)) + + def head(self): + return git(self.device, 'rev-parse', 'HEAD') + + def branch(self): + return uc.current_branch(self.device) + + def publish(self, text): + (self.seed / 'app.py').write_text(text) + git(self.seed, 'commit', '-qam', text.strip()) + git(self.seed, 'push', '-q', 'origin', 'main') + return git(self.seed, 'rev-parse', 'HEAD') + + def tag(self, name, annotated=False): + if annotated: + git(self.seed, 'tag', '-a', name, '-m', name) + else: + git(self.seed, 'tag', name) + git(self.seed, 'push', '-q', 'origin', name) + return git(self.seed, 'rev-parse', f'{name}^{{commit}}') + + def fetch(self): + assert uc.fetch(self.device).returncode == 0 + + def resolve(self, channel=None): + self.fetch() + config = {'auto_update': {'channel': channel}} if channel else {} + return uc.resolve(self.device, config) + + +STABLE = {'auto_update': {'channel': 'stable'}} +BETA = {'auto_update': {'channel': 'beta'}} + + +# -- tag parsing --------------------------------------------------------------- + +class TestReleaseTags: + @pytest.mark.parametrize('name,expected', [ + ('v3.7.0', (3, 7, 0)), + ('v0.0.1', (0, 0, 1)), + ('v10.20.30', (10, 20, 30)), + (' v3.7.0\n', (3, 7, 0)), + ]) + def test_releases_parse(self, name, expected): + assert uc.parse_release_tag(name) == expected + + @pytest.mark.parametrize('name', [ + 'v3.8.0-rc1', 'v3.8.0-beta.2', 'v3.8.0+build5', # pre-releases, build metadata + '3.7.0', 'v3.7', 'v3', 'v3.7.0.1', 'V3.7.0', # not vX.Y.Z + 'v03.7.0', 'v3.07.0', # leading zeros are not semver + 'release-3.7', 'latest', 'stable', '', None, + ]) + def test_everything_else_is_ignored(self, name): + assert uc.parse_release_tag(name) is None + + def test_newest_is_by_semver_not_by_string(self): + # String order would pick v3.9.0; v3.10.0 is newer. + assert uc.newest_release_tag(['v3.9.0', 'v3.10.0', 'v3.2.1']) == 'v3.10.0' + + def test_a_newer_pre_release_or_junk_tag_is_not_newest(self): + assert uc.newest_release_tag(['v3.7.0', 'v3.8.0-rc1', 'v99', 'nightly']) == 'v3.7.0' + + def test_no_releases(self): + assert uc.newest_release_tag(['v3.8.0-rc1', 'foo']) is None + assert uc.newest_release_tag([]) is None + + +class TestConfiguredChannel: + def test_reads_auto_update_channel(self): + assert uc.configured_channel(STABLE) == 'stable' + assert uc.configured_channel(BETA) == 'beta' + assert uc.configured_channel({'auto_update': {'channel': ' Beta '}}) == 'beta' + + @pytest.mark.parametrize('config', [ + {}, {'auto_update': {}}, {'auto_update': True}, {'auto_update': {'channel': 'nightly'}}, + {'auto_update': {'channel': 3}}, None, + ]) + def test_missing_or_nonsense_is_none(self, config): + assert uc.configured_channel(config) is None + + def test_template_default_is_stable(self): + import json + template = json.loads((ROOT / 'config' / 'config.template.json').read_text(encoding='utf-8')) + assert template['auto_update']['channel'] == 'stable' + + def test_set_channel_rejects_nonsense(self): + with pytest.raises(ValueError): + uc.set_channel(MagicMock(), 'nightly') + + +# -- channel selection, migration and no-downgrade ------------------------------- + +@needs_git +class TestResolve: + def test_beta_on_a_branch_pulls(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + status = repo.resolve('beta') + assert (status.channel, status.action) == ('beta', uc.ACTION_PULL) + + def test_no_releases_updates_as_before(self, tmp_path): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + for channel in (None, 'stable'): + status = repo.resolve(channel) + assert (status.channel, status.action, status.waiting) == ('beta', uc.ACTION_PULL, True) + assert not status.migrate + + def test_legacy_config_moves_to_a_release_that_contains_its_commit(self, tmp_path): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + sha = repo.tag('v1.0.0') + status = repo.resolve(None) + assert status.channel == 'stable' + assert status.action == uc.ACTION_CHECKOUT_TAG + assert status.target_sha == sha and status.newest_release == 'v1.0.0' + assert status.migrate, "a config without a channel must be written as stable when it moves" + + def test_legacy_config_ahead_of_the_newest_release_stays_on_main(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + repo.publish('v = 2\n') + git(repo.device, 'pull', '-q') # device now newer than v1.0.0 + status = repo.resolve(None) + assert (status.channel, status.action) == ('beta', uc.ACTION_PULL) + assert status.waiting and not status.migrate + + def test_legacy_config_on_the_release_commit_is_migrated_without_moving(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + status = repo.resolve(None) + assert (status.channel, status.action) == ('stable', uc.ACTION_NONE) + assert status.migrate and status.current_release == 'v1.0.0' + + def test_stable_never_picks_an_older_release(self, tmp_path): + """Switching beta -> stable on a device ahead of the newest release.""" + repo = Repo(tmp_path) + old_release = repo.tag('v1.0.0') + repo.publish('v = 2\n') + git(repo.device, 'pull', '-q') + status = repo.resolve('stable') + assert status.action != uc.ACTION_CHECKOUT_TAG + assert status.target_sha != old_release + assert status.waiting and 'newer than the newest release' in status.message + + def test_stable_waiting_while_detached_stays_put(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + repo.publish('v = 2\n') + git(repo.device, 'fetch', '-q') + git(repo.device, 'checkout', '-q', '--detach', 'origin/main') + status = repo.resolve('stable') + assert (status.action, status.waiting) == (uc.ACTION_NONE, True) + + def test_a_newer_release_is_taken_once_it_contains_the_commit(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + repo.publish('v = 2\n') + git(repo.device, 'pull', '-q') + repo.publish('v = 3\n') + new = repo.tag('v1.1.0', annotated=True) # annotated tags resolve to their commit + status = repo.resolve('stable') + assert (status.action, status.newest_release, status.target_sha) == ( + uc.ACTION_CHECKOUT_TAG, 'v1.1.0', new) + + def test_a_newer_pre_release_is_not_followed(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + repo.publish('v = 2\n') + repo.tag('v1.1.0-rc1') + status = repo.resolve('stable') + assert (status.action, status.newest_release) == (uc.ACTION_NONE, 'v1.0.0') + + def test_beta_on_a_detached_release_switches_back_to_main(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + git(repo.device, 'fetch', '-q', '--tags') + git(repo.device, 'checkout', '-q', '--detach', 'v1.0.0') + status = repo.resolve('beta') + assert (status.channel, status.action) == ('beta', uc.ACTION_SWITCH_TO_BETA) + + +@needs_git +class TestCheckout: + def test_refuses_to_move_backwards(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + repo.publish('v = 2\n') + git(repo.device, 'pull', '-q') + repo.fetch() + head = repo.head() + result, _ = uc.checkout_release(repo.device, 'v1.0.0') + assert result.returncode != 0 and 'backwards' in result.stderr + assert repo.head() == head + + def test_refuses_a_non_release_name(self, tmp_path): + with pytest.raises(ValueError): + uc.checkout_release(tmp_path, '--orphan') + + def test_local_edits_are_carried_across(self, tmp_path): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + repo.tag('v1.0.0') + repo.fetch() + (repo.device / 'notes.txt').write_text('my notes\n') + result, note = uc.checkout_release(repo.device, 'v1.0.0') + assert result.returncode == 0, result.stderr + assert (repo.device / 'app.py').read_text() == 'v = 2\n' + assert (repo.device / 'notes.txt').read_text() == 'my notes\n' + assert note == '' and git(repo.device, 'stash', 'list') == '' + + def test_edits_that_no_longer_apply_are_kept_in_the_stash(self, tmp_path): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + repo.tag('v1.0.0') + repo.fetch() + (repo.device / 'app.py').write_text('v = "mine"\n') # conflicts with v = 2 + result, note = uc.checkout_release(repo.device, 'v1.0.0') + assert result.returncode == 0 + assert 'stash' in note + assert (repo.device / 'app.py').read_text() == 'v = 2\n' + assert git(repo.device, 'status', '--porcelain', '--untracked-files=no') == '' + assert 'LEDMatrix autostash' in git(repo.device, 'stash', 'list') + assert git(repo.device, 'stash', 'show', '-p', 'stash@{0}').count('mine') == 1 + + +# -- Update Code (perform_core_update) against a real clone -------------------------- + +class FakeConfigManager: + def __init__(self, config): + self.config = config + self.saves = 0 + + def load_config(self): + import copy + return copy.deepcopy(self.config) + + def save_config(self, config): + self.config = config + self.saves += 1 + + +@pytest.fixture +def update_code(monkeypatch): + """The real perform_core_update, pointed at a test clone and a config.""" + from web_interface.blueprints import api_v3 as pkg + from web_interface.blueprints.api_v3 import system + + def point_at(device, config): + cm = FakeConfigManager(config) + monkeypatch.setattr(system, 'PROJECT_ROOT', device) + monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False) + monkeypatch.setattr(pkg.api_v3, 'config_manager', cm, raising=False) + monkeypatch.setattr(system, '_pip_install_requirements', + lambda *a, **k: pytest.fail('no requirements changed')) + return system.perform_core_update, cm + return point_at + + +@needs_git +class TestUpdateCode: + def test_stable_checks_out_the_newest_release(self, tmp_path, update_code): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + release = repo.tag('v1.0.0') + repo.publish('v = 3 (unreleased)\n') + update, cm = update_code(repo.device, dict(STABLE)) + result = update() + assert result['status'] == 'success', result['message'] + assert repo.head() == release and repo.branch() == '' + assert 'v1.0.0' in result['message'] and result['restart_required'] + assert result['channel'] == 'stable' + assert update()['restart_required'] is False, "second run is already up to date" + + def test_legacy_config_is_migrated_and_saved(self, tmp_path, update_code): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + release = repo.tag('v1.0.0') + update, cm = update_code(repo.device, {'auto_update': {'enabled': False}}) + assert update()['status'] == 'success' + assert repo.head() == release + assert cm.config['auto_update'] == {'enabled': False, 'channel': 'stable'} + + def test_legacy_config_ahead_of_the_release_keeps_pulling_main(self, tmp_path, update_code): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + repo.publish('v = 2\n') + git(repo.device, 'pull', '-q') + newest = repo.publish('v = 3\n') + update, cm = update_code(repo.device, {'auto_update': {'enabled': True}}) + result = update() + assert result['status'] == 'success', result['message'] + assert repo.head() == newest and repo.branch() == 'main' + assert 'channel' not in cm.config['auto_update'], "not migrated while ahead of the release" + # The next release contains the device's commit: now it moves, forward. + release = repo.tag('v1.1.0') + assert update()['status'] == 'success' + assert repo.head() == release + assert cm.config['auto_update']['channel'] == 'stable' + + def test_stable_beta_stable_round_trip_never_goes_backwards(self, tmp_path, update_code): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + r1 = repo.tag('v1.0.0') + tip = repo.publish('v = 3\n') + update, cm = update_code(repo.device, dict(STABLE)) + + assert update()['status'] == 'success' + assert (repo.head(), repo.branch()) == (r1, '') + + cm.config = dict(BETA) + result = update() + assert result['status'] == 'success', result['message'] + assert (repo.head(), repo.branch()) == (tip, 'main') + assert git(repo.device, 'rev-parse', '--abbrev-ref', '@{u}') == 'origin/main' + + cm.config = dict(STABLE) + result = update() + assert result['status'] == 'success' + assert (repo.head(), repo.branch()) == (tip, 'main'), \ + "switching to stable ahead of the newest release must not check out v1.0.0" + assert 'newer than the newest release' in result['message'] + + # A release on exactly this commit: already there, nothing moves. + assert repo.tag('v1.1.0') == tip + assert update()['status'] == 'success' + assert (repo.head(), repo.branch()) == (tip, 'main') + # The next one is newer: now it moves to the release. + repo.publish('v = 4\n') + r3 = repo.tag('v1.2.0') + assert update()['status'] == 'success' + assert (repo.head(), repo.branch()) == (r3, '') + + def test_beta_from_a_release_keeps_plugin_edits(self, tmp_path, update_code): + repo = Repo(tmp_path) + (repo.seed / 'plugin-repos').mkdir() + (repo.seed / 'plugin-repos' / 'p.py').write_text('x = 1\n') + git(repo.seed, 'add', '.') + git(repo.seed, 'commit', '-qm', 'plugin') + git(repo.seed, 'push', '-q', 'origin', 'main') + repo.tag('v1.0.0') + tip = repo.publish('v = 2\n') + git(repo.device, 'fetch', '-q', '--tags') + git(repo.device, 'checkout', '-q', '--detach', 'v1.0.0') + (repo.device / 'plugin-repos' / 'p.py').write_text('x = 2 # store update\n') + update, _ = update_code(repo.device, dict(BETA)) + result = update(stash_local_changes=False) + assert result['status'] == 'success', result['message'] + assert (repo.head(), repo.branch()) == (tip, 'main') + assert 'store update' in (repo.device / 'plugin-repos' / 'p.py').read_text() + + def test_a_failed_fetch_reports_and_changes_nothing(self, tmp_path, update_code): + repo = Repo(tmp_path) + head = repo.head() + git(repo.device, 'remote', 'set-url', 'origin', str(tmp_path / 'gone.git')) + update, _ = update_code(repo.device, dict(STABLE)) + result = update() + assert result['status'] == 'error' and result['message'].startswith('Update failed') + assert repo.head() == head + + +# -- the weekly updater and its rollback -------------------------------------------- + +def _updater(repo, config, pickup=True): + """An AutoUpdater on the clone whose core update is the real Update Code path.""" + from web_interface.blueprints import api_v3 as pkg + from web_interface.blueprints.api_v3 import system + cm = FakeConfigManager(config) + state = {} + + def run(args, **kwargs): + if args[0] == 'sudo': + return subprocess.CompletedProcess(args, 0, stdout='', stderr='') + return subprocess.run(args, **kwargs) + + def sleep(seconds): + updater = state['updater'] + if pickup and updater.request_file.exists(): + updater.request_file.unlink() + pending = au._read_json(updater.pending_file) + pending['status'] = 'verifying' + au._write_json(updater.pending_file, pending) + + updater = au.AutoUpdater( + config_manager=cm, core_update=system.perform_core_update, + project_root=repo.device, clock=lambda: 0, restart=lambda unit: None, run=run, + service_active=lambda unit: True, helper_ready=lambda: True, + disk_free=lambda path: 10 ** 12, sleep=sleep) + state['updater'] = updater + return updater, cm + + +@needs_git +class TestWeeklyUpdateAndRollback: + @pytest.fixture(autouse=True) + def wire(self, monkeypatch): + from web_interface.blueprints import api_v3 as pkg + from web_interface.blueprints.api_v3 import system + self.monkeypatch = monkeypatch + monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False) + monkeypatch.setattr(system, '_pip_install_requirements', + lambda *a, **k: pytest.fail('no requirements changed')) + + def _point(self, repo, cm): + from web_interface.blueprints import api_v3 as pkg + from web_interface.blueprints.api_v3 import system + self.monkeypatch.setattr(system, 'PROJECT_ROOT', repo.device) + self.monkeypatch.setattr(pkg.api_v3, 'config_manager', cm, raising=False) + + def test_preflight_targets_the_release_and_records_the_branch(self, tmp_path): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + release = repo.tag('v1.0.0') + updater, cm = _updater(repo, dict(STABLE)) + outcome, _, info = updater.preflight({}) + assert outcome == 'ready' + assert info['upstream_head'] == release and info['old_ref'] == 'main' + assert info['release'] == 'v1.0.0' + + def test_preflight_on_the_newest_release_is_up_to_date_and_migrates(self, tmp_path): + repo = Repo(tmp_path) + repo.tag('v1.0.0') + updater, cm = _updater(repo, {'auto_update': {'enabled': True}}) + outcome, message, _ = updater.preflight({}) + assert outcome == 'up_to_date' and 'v1.0.0' in message + assert cm.config['auto_update']['channel'] == 'stable' + + def test_a_release_that_was_rolled_back_is_not_retried(self, tmp_path): + repo = Repo(tmp_path) + repo.publish('v = 2\n') + release = repo.tag('v1.0.0') + updater, _ = _updater(repo, dict(STABLE)) + outcome, message, _ = updater.preflight({'rolled_back_head': release}) + assert outcome == 'up_to_date' and 'v1.0.0' in message + + def test_move_to_a_release_then_roll_back_returns_to_main(self, tmp_path): + repo = Repo(tmp_path) + before = repo.head() + repo.publish('v = 2\n') + release = repo.tag('v1.0.0') + updater, cm = _updater(repo, dict(STABLE)) + self._point(repo, cm) + result = updater.run() + assert result['core_outcome'] == 'verifying', result['core_message'] + assert 'v1.0.0' in result['core_message'] + pending = au._read_json(updater.pending_file) + assert (pending['old_ref'], pending['new_head'], pending['release']) == ('main', release, 'v1.0.0') + assert (repo.head(), repo.branch()) == (release, '') + + ok, detail = av.Verifier(repo.device).rollback(pending) + assert ok, detail + assert (repo.head(), repo.branch()) == (before, 'main'), \ + "rollback must put HEAD back on main, not leave it detached" + assert git(repo.device, 'rev-parse', 'main') == before + + def test_back_to_main_then_roll_back_returns_to_the_release(self, tmp_path): + repo = Repo(tmp_path) + release = repo.tag('v1.0.0') + tip = repo.publish('v = 2\n') + git(repo.device, 'fetch', '-q', '--tags') + git(repo.device, 'checkout', '-q', '--detach', 'v1.0.0') + updater, cm = _updater(repo, dict(BETA)) + self._point(repo, cm) + result = updater.run() + assert result['core_outcome'] == 'verifying', result['core_message'] + pending = au._read_json(updater.pending_file) + assert pending['old_ref'] == '' and (repo.head(), repo.branch()) == (tip, 'main') + + ok, detail = av.Verifier(repo.device).rollback(pending) + assert ok, detail + assert (repo.head(), repo.branch()) == (release, '') + assert git(repo.device, 'rev-parse', 'main') == tip, \ + "rolling back from main to a release must not drag main backwards" + + def test_rollback_without_old_ref_behaves_as_before(self, tmp_path): + repo = Repo(tmp_path) + old = repo.head() + new = repo.publish('v = 2\n') + git(repo.device, 'pull', '-q') + ok, _ = av.Verifier(repo.device).rollback({'old_head': old, 'new_head': new}) + assert ok and (repo.head(), repo.branch()) == (old, 'main') + + +# -- API and the General tab --------------------------------------------------------- + +@pytest.fixture +def client(monkeypatch, tmp_path): + from web_interface.blueprints import api_v3 as pkg + from web_interface.blueprints.api_v3 import api_v3, system + app = Flask(__name__) + app.config['TESTING'] = True + app.register_blueprint(api_v3, url_prefix='/api/v3') + cm = FakeConfigManager({'auto_update': {'enabled': False}}) + monkeypatch.setattr(pkg.api_v3, 'config_manager', cm, raising=False) + monkeypatch.setattr(pkg.api_v3, 'plugin_catalog', None, raising=False) + repo = Repo(tmp_path) + monkeypatch.setattr(system, 'PROJECT_ROOT', repo.device) + c = app.test_client() + c.cm, c.repo = cm, repo + return c + + +@needs_git +class TestChannelApi: + def test_rejects_an_unknown_channel(self, client): + for body in ({'channel': 'nightly'}, {}, ['stable']): + r = client.post('/api/v3/system/update-channel', json=body) + assert r.status_code == 400 + assert 'channel' not in client.cm.config['auto_update'] + + def test_switch_to_beta_and_back(self, client): + r = client.post('/api/v3/system/update-channel', json={'channel': 'beta'}) + assert r.status_code == 200 and client.cm.config['auto_update']['channel'] == 'beta' + assert r.get_json()['data']['channel'] == 'beta' + + client.repo.tag('v1.0.0') + client.repo.publish('v = 2\n') + git(client.repo.device, 'pull', '-q') + client.repo.fetch() + r = client.post('/api/v3/system/update-channel', json={'channel': 'stable'}).get_json() + assert client.cm.config['auto_update']['channel'] == 'stable' + assert r['data']['waiting'] is True + assert 'newer than the newest release' in r['message'], \ + "switching to stable ahead of a release must say it keeps following main" + + def test_get_reports_the_plan(self, client): + client.repo.publish('v = 2\n') + client.repo.tag('v1.0.0') + data = client.get('/api/v3/system/update-channel?fetch=1').get_json()['data'] + assert data['newest_release'] == 'v1.0.0' and data['action'] == uc.ACTION_CHECKOUT_TAG + assert data['configured'] is None and data['channel'] == 'stable' + + def test_check_update_compares_release_tags_on_stable(self, client, monkeypatch): + from web_interface.blueprints import api_v3 as pkg + monkeypatch.setitem(pkg._update_check_cache, 'result', None) + client.cm.config = dict(STABLE) + client.repo.tag('v1.0.0') + client.repo.publish('v = 2 (unreleased)\n') + client.repo.fetch() + git(client.repo.device, 'checkout', '-q', '--detach', 'v1.0.0') + data = client.get('/api/v3/system/check-update').get_json() + assert data['update_available'] is False, "main being ahead is not an update on stable" + assert data['channel'] == 'stable' and data['current_release'] == 'v1.0.0' + + client.repo.publish('v = 3\n') + client.repo.tag('v1.1.0') + monkeypatch.setitem(pkg._update_check_cache, 'result', None) + data = client.get('/api/v3/system/check-update').get_json() + assert data['update_available'] is True and data['target_version'] == 'v1.1.0' + assert data['commits_behind'] == 2 + + def _check_update(self, monkeypatch): + from web_interface.blueprints import api_v3 as pkg + monkeypatch.setitem(pkg._update_check_cache, 'result', None) + return self.client.get('/api/v3/system/check-update').get_json() + + def _update_code(self, monkeypatch): + from web_interface.blueprints import api_v3 as pkg + from web_interface.blueprints.api_v3 import system + monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False) + monkeypatch.setattr(system, '_pip_install_requirements', + lambda *a, **k: pytest.fail('no requirements changed')) + return system.perform_core_update() + + @pytest.mark.parametrize('config', [STABLE, {'auto_update': {'enabled': True}}], + ids=['stable', 'legacy']) + def test_check_update_detached_ahead_of_the_release_is_not_an_update( + self, client, monkeypatch, config): + # The ledpi rig: a detached HEAD that pulled main after the newest + # release, with main moved on since. Update Code leaves it where it + # is, so check-update must not offer "12 commits" and an Update Now + # that then answers "already up to date". + self.client = client + client.cm.config = {k: dict(v) for k, v in config.items()} + repo = client.repo + repo.tag('v1.0.0') + ahead = repo.publish('v = 2 (unreleased)\n') + repo.publish('v = 3 (unreleased)\n') + repo.fetch() + git(repo.device, 'checkout', '-q', '--detach', ahead) + + data = self._check_update(monkeypatch) + assert data['update_available'] is False, \ + "the channel's action is none: nothing for Update Code to install" + assert data['commits_behind'] == 0 and data['waiting'] is True + general_tab = uc.resolve(repo.device, client.cm.load_config()).message + assert data['channel_message'] == general_tab + assert 'moves to the first release that includes it' in data['channel_message'] + + result = self._update_code(monkeypatch) + assert result['status'] == 'success' and 'already up to date' in result['message'] + assert (repo.head(), repo.branch()) == (ahead, ''), "Update Code agrees: nothing moved" + + info = client.get('/api/v3/system/git-info').get_json() + assert info['detached'] is True and info['current_release'] is None + assert info['channel_message'] == general_tab + + @pytest.mark.parametrize('config', [BETA, STABLE], ids=['beta', 'stable-waiting']) + def test_check_update_on_a_branch_behind_main_is_an_update(self, client, monkeypatch, config): + # beta, and stable waiting on a branch for a release: both pull main. + self.client = client + client.cm.config = {k: dict(v) for k, v in config.items()} + repo = client.repo + repo.tag('v1.0.0') + repo.publish('v = 2 (unreleased)\n') + git(repo.device, 'pull', '-q') + tip = repo.publish('v = 3 (unreleased)\n') + + data = self._check_update(monkeypatch) + assert data['update_available'] is True and data['commits_behind'] == 1 + assert data['remote_sha'] == tip and 'target_version' not in data + + result = self._update_code(monkeypatch) + assert result['status'] == 'success', result['message'] + assert (repo.head(), repo.branch()) == (tip, 'main'), "Update Code agrees: it pulled main" + + def test_git_info_names_the_release_when_on_one(self, client): + client.cm.config = dict(STABLE) + client.repo.tag('v1.0.0') + client.repo.fetch() + git(client.repo.device, 'checkout', '-q', '--detach', 'v1.0.0') + info = client.get('/api/v3/system/git-info').get_json() + assert info['detached'] is True and info['current_release'] == 'v1.0.0' + + def test_general_form_saves_the_channel(self, client, monkeypatch): + r = client.post('/api/v3/config/main', json={'auto_update_channel': 'beta'}) + assert r.status_code == 200, r.get_json() + assert client.cm.config['auto_update']['channel'] == 'beta' + r = client.post('/api/v3/config/main', json={'auto_update_channel': 'weekly'}) + assert r.status_code == 400 + assert client.cm.config['auto_update']['channel'] == 'beta' diff --git a/test/test_web_auth.py b/test/test_web_auth.py index 4279047b..819ea4f1 100644 --- a/test/test_web_auth.py +++ b/test/test_web_auth.py @@ -190,6 +190,22 @@ class TestTurnedOn: assert r.status_code == 401 assert 'tokens' not in secrets_on_disk(config_manager)['web_auth'] + def test_the_update_channel_needs_login(self, config_manager, api_v3_module): + # It decides what code the next update installs: no route of its own + # opts out of the login, so the default rule covers it. + app = build(config_manager, api_v3_module) + signed_in = enable(app) + stranger = lan_client(app) + assert stranger.get('/api/v3/system/update-channel').status_code == 401 + r = stranger.post('/api/v3/system/update-channel', json={'channel': 'beta'}) + assert r.status_code == 401 + # (The config template's migration writes the default, stable.) + assert config_manager.load_config()['auto_update'].get('channel') != 'beta' + + r = signed_in.post('/api/v3/system/update-channel', json={'channel': 'beta'}) + assert r.status_code == 200, r.get_json() + assert config_manager.load_config()['auto_update']['channel'] == 'beta' + def test_unknown_paths_do_not_leak_a_404_first(self, config_manager, api_v3_module): app = build(config_manager, api_v3_module) enable(app) diff --git a/web_interface/auto_update.py b/web_interface/auto_update.py index 1a9012be..f15a201e 100644 --- a/web_interface/auto_update.py +++ b/web_interface/auto_update.py @@ -10,6 +10,9 @@ Nothing here is allowed to leave a device broken without saying so: the checkout has local edits or commits, a rebase or merge is in progress, the branch has no upstream, disk is low, the newest commit was already rolled back once, or the health check is not set up. +* **On its channel.** ``auto_update.channel`` picks the newest release tag + (stable) or main (beta); web_interface/update_channel.py decides, and never + moves a device to an older commit than the one it runs. * **Verified, and rolled back.** The pull itself is the Overview "Update Code" path (``perform_core_update``). Restarting and checking the result is handed to ledmatrix-update-verify.service (scripts/utils/auto_update_verify.py), @@ -39,6 +42,8 @@ import time from datetime import datetime from pathlib import Path +from web_interface import update_channel + logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).resolve().parent.parent @@ -159,6 +164,12 @@ def _short(sha): return (sha or 'unknown')[:7] +def _label(pending): + """The new version for messages: its release tag when it is one, else the short commit.""" + release, new = pending.get('release'), pending.get('new_head') + return f'{release} ({_short(new)})' if release else _short(new) + + def is_due(now, next_due, local_hour): """Due once ``next_due`` has passed, in quiet hours or after the grace period.""" if next_due is None or now < next_due: @@ -483,10 +494,6 @@ class AutoUpdater: return 'blocked', ('A git merge is in progress in the LEDMatrix folder. ' 'Finish or abort it; automatic updates will not touch it.'), {} - if self._git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}').returncode != 0: - return 'blocked', ('The current branch has no upstream to update from (or HEAD is detached). ' - 'Use Update Code once, or Tools -> Switch branch.'), {} - # The same predicate perform_core_update refuses on when called from # here, so what passes this check is never stashed by the pull. changed = local_changes(self.project_root, run=self.run_command) @@ -498,24 +505,64 @@ class AutoUpdater: return 'blocked', (f'Only {free // (1024 * 1024)} MB of disk space is free; an update ' f'needs at least {MIN_FREE_BYTES // (1024 * 1024)} MB.'), {} - fetch = self._git('fetch', '--quiet', timeout=120) + fetch = update_channel.fetch(self.project_root, run=self.run_command) if fetch.returncode != 0: detail = next((ln.strip() for ln in (fetch.stderr or '').splitlines() if ln.strip()), '') return 'error', f'Could not check for LEDMatrix updates: {detail or "git fetch failed"}.', {} - ahead = self._count('@{u}..HEAD') - if ahead: - return 'blocked', (f'This checkout has {ahead} local commit(s) that are not upstream. ' - 'Automatic updates will not rebase them; update manually with Update Code.'), {} - if not self._count('HEAD..@{u}'): - return 'up_to_date', 'LEDMatrix is already up to date.', {} - - upstream = self._git('rev-parse', '@{u}').stdout.strip() - if upstream and upstream == state.get('rolled_back_head'): - return 'up_to_date', (f'The newest LEDMatrix version ({_short(upstream)}) failed its health ' - 'check and was rolled back before; waiting for a newer one.'), {} + # Where the update goes: the newest release (stable) or main (beta). + channel = update_channel.resolve(self.project_root, self._config(), run=self.run_command) + if channel.migrate and channel.action == update_channel.ACTION_NONE: + # Already on the newest release: nothing to update, but a config + # from before channels existed now says stable. + self._persist_stable_channel() head = self._git('rev-parse', 'HEAD').stdout.strip() - return 'ready', '', {'old_head': head, 'upstream_head': upstream} + old_ref = update_channel.current_branch(self.project_root, run=self.run_command) + if channel.action == update_channel.ACTION_NONE: + return 'up_to_date', channel.message, {} + if channel.action == update_channel.ACTION_CHECKOUT_TAG: + target, label = channel.target_sha, channel.newest_release + elif channel.action == update_channel.ACTION_SWITCH_TO_BETA: + beta = f'{update_channel.REMOTE}/{update_channel.BETA_BRANCH}' + if self._count(f'{beta}..HEAD'): + return 'blocked', (f'This checkout has local commits that are not on {beta}. Automatic ' + 'updates will not leave them behind; update manually with Update Code.'), {} + target, label = self._git('rev-parse', beta).stdout.strip(), update_channel.BETA_BRANCH + if target == head: + return 'up_to_date', 'LEDMatrix is already up to date.', {} + else: + if self._git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}').returncode != 0: + return 'blocked', ('The current branch has no upstream to update from (or HEAD is detached). ' + 'Use Update Code once, or Tools -> Switch branch.'), {} + ahead = self._count('@{u}..HEAD') + if ahead: + return 'blocked', (f'This checkout has {ahead} local commit(s) that are not upstream. ' + 'Automatic updates will not rebase them; update manually with Update Code.'), {} + if not self._count('HEAD..@{u}'): + return 'up_to_date', 'LEDMatrix is already up to date.', {} + target = self._git('rev-parse', '@{u}').stdout.strip() + label = _short(target) + + if target and target == state.get('rolled_back_head'): + return 'up_to_date', (f'The newest LEDMatrix version ({label}) failed its health ' + 'check and was rolled back before; waiting for a newer one.'), {} + return 'ready', '', {'old_head': head, 'upstream_head': target, 'old_ref': old_ref, + 'release': channel.newest_release + if channel.action == update_channel.ACTION_CHECKOUT_TAG else None} + + def _config(self): + try: + return self.config_manager.load_config() or {} + except Exception: + logger.debug("Auto-update could not load config for the channel", exc_info=True) + return {} + + def _persist_stable_channel(self): + try: + update_channel.set_channel(self.config_manager, 'stable') + logger.info("Update channel set to stable: this device is on a release now") + except Exception: + logger.warning("Could not save the stable update channel", exc_info=True) def update_core(self, state): try: @@ -549,6 +596,11 @@ class AutoUpdater: 'status': 'pending', 'old_head': old_head, 'new_head': new_head, + # Where HEAD was: a branch name, or '' when detached on a release. + # The rollback returns there, not just to the commit, so a move + # between main and a release tag is undone completely. + 'old_ref': info.get('old_ref'), + 'release': info.get('release') if new_head == info.get('upstream_head') else None, 'display_was_active': display_was_active, 'dependency_failures': list(core.get('dependency_failures') or []), 'created_at': self.clock(), @@ -570,7 +622,7 @@ class AutoUpdater: return {'outcome': 'up_to_date', 'message': core.get('message') or 'LEDMatrix is already up to date.'} handoff = {'outcome': 'verifying', - 'message': (f'Updated LEDMatrix from {_short(old_head)} to {_short(new_head)}; ' + 'message': (f'Updated LEDMatrix from {_short(old_head)} to {_label(pending)}; ' 'restarting and checking the services.')} # Recorded before the handoff: the health check restarts this process. self._store_run(state, handoff, [], []) @@ -626,11 +678,11 @@ class AutoUpdater: f'See "journalctl -u {VERIFY_UNIT}".') elif status == 'success': outcome = 'updated' - message = (f'Updated LEDMatrix from {_short(old)} to {_short(new)}; ' + message = (f'Updated LEDMatrix from {_short(old)} to {_label(pending)}; ' 'the services restarted and stayed healthy.') elif status == 'rolled_back': outcome = 'rolled_back' - message = (f'The LEDMatrix update to {_short(new)} was rolled back to {_short(old)} because ' + message = (f'The LEDMatrix update to {_label(pending)} was rolled back to {_short(old)} because ' f'{reason or "it failed its health check"}.' + (f' Note: {detail}.' if detail else '')) state['rolled_back_head'] = new else: diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index 6a774e8c..dd5bbe64 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -31,7 +31,7 @@ FORM_SECTION_FIELD = '__form_section' #: Fields of the General tab. Any one of them in a /config/main post means the #: General form was submitted, so its unchecked checkboxes read as False. GENERAL_FIELDS = ('timezone', 'city', 'state', 'country', 'web_display_autostart', - 'plugins_directory', 'auto_update_enabled') + 'plugins_directory', 'auto_update_enabled', 'auto_update_channel') #: Top-level fields save_main_config stores somewhere of its own (location, #: plugin_system, ...), never as a config key of the same name. @@ -522,6 +522,20 @@ def save_main_config(): if not isinstance(current_config.get('auto_update'), dict): current_config['auto_update'] = {} _set_checkbox(current_config['auto_update'], 'enabled', 'auto_update_enabled') + if 'auto_update_channel' in data: + # stable/beta (web_interface/update_channel.py). Only stored + # here; the next update applies it, never moving backwards. + from web_interface import update_channel + channel = update_channel.normalize_channel(data['auto_update_channel']) + if channel is None: + return jsonify({'status': 'error', + 'message': "auto_update_channel must be 'stable' or 'beta'"}), 400 + if not isinstance(current_config.get('auto_update'), dict): + current_config['auto_update'] = {} + if current_config['auto_update'].get('channel') != channel: + current_config['auto_update']['channel'] = channel + # The Overview banner compares against the channel's target. + _pkg._update_check_cache['result'] = None if 'timezone' in data: current_config['timezone'] = data['timezone'] diff --git a/web_interface/blueprints/api_v3/system.py b/web_interface/blueprints/api_v3/system.py index 51720ed3..e6f17628 100644 --- a/web_interface/blueprints/api_v3/system.py +++ b/web_interface/blueprints/api_v3/system.py @@ -80,18 +80,88 @@ def dismiss_auto_update_alert(): return jsonify({'status': 'success'}) +def _channel_payload(channel, fetch_error=''): + from web_interface import update_channel + data = dict(channel) + data['channels'] = list(update_channel.CHANNELS) + data['fetch_error'] = fetch_error or None + return data + + +@api_v3.route('/system/update-channel', methods=['GET']) +def get_update_channel(): + """The update channel: configured, in effect, and what the next update does. + + Reads local refs only, unless ``?fetch=1`` asks it to check origin first. + No local except: failures reach the blueprint-wide handler, which logs the + traceback and returns the redacted detail. + """ + fetch = str(request.args.get('fetch', '')).lower() in ('1', 'true', 'yes') + channel, fetch_error = channel_status(fetch=fetch) + return jsonify({'status': 'success', 'data': _channel_payload(channel, fetch_error)}) + + +@api_v3.route('/system/update-channel', methods=['POST']) +def set_update_channel(): + """Switch between the stable and beta update channels: ``{"channel": "stable"}``. + + Only the setting changes here; the next Update Code or weekly update + applies it. Switching to stable never moves a device backwards: one + running code newer than the newest release keeps following main until a + release includes it, and the response says so. + """ + from web_interface import update_channel + payload = request.get_json(silent=True) + channel = update_channel.normalize_channel(payload.get('channel')) if isinstance(payload, dict) else None + if channel is None: + return jsonify({'status': 'error', + 'message': "channel must be 'stable' or 'beta'"}), 400 + cm = getattr(api_v3, 'config_manager', None) + if not cm: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 503 + update_channel.set_channel(cm, channel) + _update_check_cache['result'] = None + status, _ = channel_status(fetch=False) + if channel == 'beta': + message = (f'Switched to the beta channel. Updates now follow {update_channel.BETA_BRANCH}, ' + 'the newest code, before it is released.') + elif status.action == update_channel.ACTION_CHECKOUT_TAG: + message = (f'Switched to the stable channel. The next update moves this device to release ' + f'{status.newest_release}.') + else: + # On the newest release already, or waiting for one that includes + # this commit; status.message says which. + message = f'Switched to the stable channel. {status.message}' + if channel == 'beta' or status.action == update_channel.ACTION_CHECKOUT_TAG: + message += ' Use Update Code on the Overview tab to apply it now.' + return jsonify({'status': 'success', 'message': message, 'data': _channel_payload(status)}) + + @api_v3.route('/system/check-update', methods=['GET']) def check_for_update(): - """Check whether a newer LEDMatrix commit is available on origin/main.""" + """Check whether newer LEDMatrix code is available on this device's update channel. + + stable compares HEAD with the newest release tag; beta (and stable while + it waits on a branch for a release newer than this commit) with + origin/main. When the channel says an update would do nothing -- on the + newest release, or detached and newer than it -- it is never reported + as available, whatever origin/main holds. The + response carries ``channel``, ``waiting``, ``newest_release`` and, when + the update is a release, ``target_version``. + """ now = _pkg.time.time() if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL: return jsonify(_update_check_cache['result']) + from web_interface import update_channel _safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0} try: cwd = str(PROJECT_ROOT) fetch_result = subprocess.run( - ['git', 'fetch', 'origin', 'main', '--quiet'], + # main and the release tags only: this runs on page loads, with + # a short timeout, and other branches are not needed to answer. + ['git', 'fetch', '--quiet', '--tags', '--force', update_channel.REMOTE, + update_channel.BETA_BRANCH], capture_output=True, timeout=10, cwd=cwd, ) if fetch_result.returncode != 0: @@ -102,6 +172,33 @@ def check_for_update(): _update_check_cache['result'] = failed _update_check_cache['ts'] = now return jsonify(failed) + + channel, _ = channel_status(cwd, fetch=False) + channel_fields = {'channel': channel.channel, 'configured_channel': channel.configured, + 'waiting': channel.waiting, 'newest_release': channel.newest_release, + 'current_release': channel.current_release, + 'channel_message': channel.message} + if channel.channel == 'stable' or channel.action == update_channel.ACTION_NONE: + # On a release (or about to move to one): compare tags, not + # branch commits -- main is always ahead of the newest release. + # ACTION_NONE covers a detached HEAD newer than the newest + # release too: Update Code leaves it where it is until a release + # includes it, so origin/main being ahead is not an update it + # would install. channel_message says so, in the General tab's + # words. + result = {'update_available': False, 'remote_sha': channel.newest_release_sha or 'unknown', + 'commits_behind': 0, **channel_fields} + if channel.action == update_channel.ACTION_CHECKOUT_TAG: + count_str = subprocess.run( + ['git', 'rev-list', '--count', f'HEAD..{channel.newest_release_sha}'], + capture_output=True, text=True, timeout=5, cwd=cwd, + ).stdout.strip() + result.update(update_available=True, target_version=channel.newest_release, + commits_behind=int(count_str) if count_str.isdigit() else 0) + _update_check_cache['result'] = result + _update_check_cache['ts'] = now + return jsonify(result) + local = subprocess.run( ['git', 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=5, cwd=cwd, @@ -112,7 +209,7 @@ def check_for_update(): ).stdout.strip() if not local or not remote: - return jsonify(_safe) + return jsonify({**_safe, **channel_fields}) if local == remote: result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0} @@ -123,6 +220,7 @@ def check_for_update(): ).stdout.strip() count = int(count_str) if count_str.isdigit() else 0 result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count} + result.update(channel_fields) _update_check_cache['result'] = result _update_check_cache['ts'] = now @@ -190,16 +288,76 @@ def perform_core_update(stash_local_changes=True): _core_update_lock.release() +def _load_config_quietly(): + # getattr: the blueprint only has a config_manager once the app wired one. + cm = getattr(api_v3, 'config_manager', None) + if not cm: + return {} + try: + return cm.load_config() or {} + except Exception: + logger.warning("Could not load config to read the update channel", exc_info=True) + return {} + + +def _persist_stable_channel(): + """A config that predates channels moves to stable once it is on a release.""" + cm = getattr(api_v3, 'config_manager', None) + if not cm: + return + from web_interface import update_channel + try: + update_channel.set_channel(cm, 'stable') + logger.info("Update channel set to stable: this device is on a release now") + except Exception: + logger.warning("Could not save the stable update channel", exc_info=True) + + +def channel_status(project_dir=None, fetch=True): + """The update channel's plan for this checkout (update_channel.resolve). + + Returns ``(status, fetch_error)``; ``fetch_error`` is git's first line + when fetching failed, else ''. + """ + from web_interface import update_channel + project_dir = str(project_dir or PROJECT_ROOT) + fetch_error = '' + if fetch: + fetched = update_channel.fetch(project_dir) + if fetched.returncode != 0: + stderr = fetched.stderr.decode(errors='replace') if isinstance(fetched.stderr, bytes) else (fetched.stderr or '') + fetch_error = next((ln.strip() for ln in stderr.splitlines() if ln.strip()), 'git fetch failed') + return update_channel.resolve(project_dir, _load_config_quietly()), fetch_error + + def _perform_core_update_locked(stash_local_changes=True): project_dir = str(PROJECT_ROOT) + from web_interface import update_channel + + # Which code to move to: the newest release (stable) or main (beta). + # See web_interface/update_channel.py; it never picks an older commit. + channel, fetch_error = channel_status(project_dir) + if fetch_error: + logger.warning("git fetch failed before update: %s", fetch_error) + return {'status': 'error', 'message': f"Update failed: {fetch_error}", + 'restart_required': False, 'dependency_failures': []} + action = channel.action + if action == update_channel.ACTION_NONE: + if channel.migrate: + _persist_stable_channel() + return {'status': 'success', 'restart_required': False, 'dependency_failures': [], + 'channel': channel.channel, + 'message': f"LEDMatrix is already up to date. {channel.message}"} # Decide how to pull BEFORE stashing. If this checkout cannot be # updated at all, stashing first would put the user's local changes # away for an update that was never going to run. - pull_args, upstream_note, pull_error = resolve_pull_command(project_dir) - if pull_error: - logger.warning("git pull not attempted: %s", pull_error) - return {'status': 'error', 'message': pull_error, 'restart_required': False} + pull_args, upstream_note = None, '' + if action == update_channel.ACTION_PULL: + pull_args, upstream_note, pull_error = resolve_pull_command(project_dir) + if pull_error: + logger.warning("git pull not attempted: %s", pull_error) + return {'status': 'error', 'message': pull_error, 'restart_required': False} # Local changes, counted exactly as the automatic update's preflight # counts them (auto_update.local_changes): mode-only changes and the @@ -261,15 +419,30 @@ def _perform_core_update_locked(stash_local_changes=True): # to restart onto code whose dependencies did not install. dependency_failures = [] - # Perform the git pull. Branches without an upstream were given - # an explicit "origin " above so the update still works. - result = subprocess.run( - pull_args, - capture_output=True, - text=True, - timeout=60, - cwd=project_dir - ) + # Move the checkout. A pull on beta; branches without an upstream were + # given an explicit "origin " above so the update still works. + # Stable checks out the release tag, carrying edits across the way the + # pull's --autostash does. + channel_note = '' + if action == update_channel.ACTION_CHECKOUT_TAG: + result, autostash_note = update_channel.checkout_release(project_dir, channel.newest_release) + channel_note = f"Now on release {channel.newest_release} (stable channel). {autostash_note}".strip() + elif action == update_channel.ACTION_SWITCH_TO_BETA: + result, autostash_note = update_channel.checkout_beta_branch(project_dir) + if result.returncode == 0: + result = subprocess.run(['git', 'pull', '--rebase', '--autostash'], + capture_output=True, text=True, timeout=60, cwd=project_dir) + channel_note = f"Now following {update_channel.BETA_BRANCH} (beta channel). {autostash_note}".strip() + else: + result = subprocess.run( + pull_args, + capture_output=True, + text=True, + timeout=60, + cwd=project_dir + ) + if channel.waiting and channel.newest_release: + channel_note = channel.message # Give the branch tracking information so the next pull is a plain # `git pull` — otherwise every update repeats the fallback. @@ -288,10 +461,16 @@ def _perform_core_update_locked(stash_local_changes=True): pull_message = "Code updated successfully." if has_changes: pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}" - if result.stdout and "Already up to date" not in result.stdout: + # A checkout (a channel move) prints nothing; it always moved. + if (action != update_channel.ACTION_PULL + or (result.stdout and "Already up to date" not in result.stdout)): pull_message = f"Code updated successfully.{stash_info}" if upstream_note: pull_message = f"{pull_message} {upstream_note}" + if channel_note: + pull_message = f"{pull_message} {channel_note}" + if channel.migrate and action == update_channel.ACTION_CHECKOUT_TAG: + _persist_stable_channel() # Keep Python dependencies in sync automatically: if the pull # changed a requirements file, install it now — users updating @@ -376,6 +555,7 @@ def _perform_core_update_locked(stash_local_changes=True): 'message': pull_message, 'restart_required': bool(result.returncode == 0 and code_changed), 'dependency_failures': dependency_failures, + 'channel': channel.channel, } @@ -619,8 +799,23 @@ def get_git_info(): remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d) branch_name = branch.stdout.strip() upstream = _git_upstream(d) + current_release, channel_message = None, '' + if not branch_name: + # Detached is not always "on a release": a device that pulled + # main and was then detached is newer than the newest one. + # Local refs only; the panel must not wait on the network. + try: + channel, _ = channel_status(d, fetch=False) + current_release, channel_message = channel.current_release, channel.message + except Exception: + logger.debug("git-info: could not read the update channel", exc_info=True) return jsonify({ 'branch': branch_name, + # No branch: the stable update channel checks out release tags. + 'detached': not branch_name, + 'version': get_git_version(), + 'current_release': current_release, + 'channel_message': channel_message, 'dirty': bool(status.stdout.strip()), 'status': status.stdout.strip(), 'recent_commits': log.stdout.strip() if log.returncode == 0 else '', diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 4a15220d..0c32779e 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -476,9 +476,17 @@ def _load_general_partial(): except Exception: logger.debug("Could not read auto-update status", exc_info=True) auto_update_status = None + try: + # Local refs only: a page load must not wait on the network. + from web_interface import update_channel + update_channel_status = update_channel.resolve(update_channel.PROJECT_ROOT, main_config) + except Exception: + logger.debug("Could not read the update channel", exc_info=True) + update_channel_status = None return render_template('v3/partials/general.html', main_config=main_config, auto_update_status=auto_update_status, + update_channel_status=update_channel_status, web_login=_web_login_state()) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 87c743f1..87cfb2e0 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -1052,7 +1052,12 @@ if (data.update_available && getDismissedSha() !== data.remote_sha) { var n = data.commits_behind || 0; var msg = 'A new LEDMatrix update is available'; - if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')'; + if (data.target_version) { + // Stable channel: the update is a release. + msg = 'LEDMatrix ' + data.target_version + ' is available'; + } else if (n > 0) { + msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')'; + } document.getElementById('update-banner-text').textContent = msg; banner.style.display = ''; try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {} diff --git a/web_interface/templates/v3/partials/general.html b/web_interface/templates/v3/partials/general.html index 5d1e71a2..b3776426 100644 --- a/web_interface/templates/v3/partials/general.html +++ b/web_interface/templates/v3/partials/general.html @@ -69,6 +69,22 @@ {% endif %} + + {% set configured_channel = (main_config.auto_update or {}).channel if (main_config.auto_update or {}).channel in ('stable', 'beta') else 'stable' %} +
+ + + {% if update_channel_status %} +

+ {{ update_channel_status.message }} +

+ {% endif %} +

Takes effect at the next update: use Update Code on the Overview tab to apply it now.

+
+
diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index 013784b0..c8dc8b2e 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -519,7 +519,7 @@ let html = `
- ${escHtml(d.branch || 'unknown')} + ${escHtml(d.detached ? (d.version || 'detached') : (d.branch || 'unknown'))} ${dirtyBadge}
`; @@ -534,7 +534,14 @@
`; } - if (d.upstream) { + if (d.detached && d.current_release) { + // The stable update channel sits on release tags, with no branch. + html += `

On release ${escHtml(d.current_release)}, not a branch (stable update channel). Pull Latest follows the update channel set on the General tab.

`; + } else if (d.detached) { + // Detached but not on a release: say what the channel does + // with it, in the General tab's words. + html += `

Not on a branch or a release. ${escHtml(d.channel_message || '')} Pull Latest follows the update channel set on the General tab.

`; + } else if (d.upstream) { html += `

tracking ${escHtml(d.upstream)}

`; } else if (d.can_pull) { html += `

No upstream set; Pull Latest will use origin/${escHtml(d.branch || '')} and set it.

`; diff --git a/web_interface/update_channel.py b/web_interface/update_channel.py new file mode 100644 index 00000000..f7fdb3f2 --- /dev/null +++ b/web_interface/update_channel.py @@ -0,0 +1,263 @@ +"""Update channels: which LEDMatrix code "Update Code" and the weekly updater move to. + +* **stable** follows releases: the newest ``vX.Y.Z`` tag, by semantic version. + Pre-release tags (``v3.8.0-rc1``) and anything else that is not exactly + ``vX.Y.Z`` are ignored. The checkout sits on the tag with a detached HEAD. +* **beta** is how every device updated before channels existed: it follows + ``main`` (``git pull --rebase --autostash`` on the current branch). + +The setting is ``auto_update.channel`` in config.json. New installs get +``stable`` from config/config.template.json (and the installer). + +**Nobody is moved backwards.** stable only ever checks out a release tag that +contains the current commit, so a device running code newer than the newest +release -- anything that pulled main since that release, or a fresh install +of main -- keeps following main ("waiting") until a release that contains its +commit exists, and moves to it at the next update. A config written before +channels existed (no key) behaves the same way, and the key is written as +``stable`` when that move happens. + +Standard library only, and every git call goes through ``run`` (default: +``subprocess.run`` looked up at call time, so tests that patch it are seen). +""" +import re +import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CHANNELS = ('stable', 'beta') +DEFAULT_CHANNEL = 'stable' +#: The branch beta follows, and the one a device on a release tag moves to. +BETA_BRANCH = 'main' +REMOTE = 'origin' + +#: Exactly vMAJOR.MINOR.PATCH, no leading zeros, nothing after it. +_RELEASE_TAG_RE = re.compile(r'v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)') + +#: Actions an update can take (``ChannelStatus.action``). +ACTION_NONE = 'none' # nothing to do: on the newest release, or waiting +ACTION_CHECKOUT_TAG = 'checkout_tag' # stable: move forward to the newest release tag +ACTION_PULL = 'pull' # beta (or waiting for a release): pull the current branch +ACTION_SWITCH_TO_BETA = 'switch_to_beta' # beta on a detached release tag: go back to main + +AUTOSTASH_MESSAGE = 'LEDMatrix autostash before update' + + +def parse_release_tag(name): + """``(major, minor, patch)`` for a release tag such as ``v3.7.0``, else None.""" + match = _RELEASE_TAG_RE.fullmatch((name or '').strip()) + return tuple(int(part) for part in match.groups()) if match else None + + +def newest_release_tag(names): + """The highest release tag among ``names`` by semver, or None.""" + releases = [(parse_release_tag(n), n.strip()) for n in names or ()] + releases = [pair for pair in releases if pair[0] is not None] + return max(releases)[1] if releases else None + + +def normalize_channel(value): + """'stable' or 'beta' from user input, else None.""" + if not isinstance(value, str): + return None + value = value.strip().lower() + return value if value in CHANNELS else None + + +def configured_channel(config): + """The channel config.json names, or None when it names none (or nonsense).""" + section = (config or {}).get('auto_update') + if not isinstance(section, dict): + return None + return normalize_channel(section.get('channel')) + + +def set_channel(config_manager, channel): + """Write ``auto_update.channel`` to config.json. Returns the saved config.""" + channel = normalize_channel(channel) + if channel is None: + raise ValueError(f"channel must be one of {', '.join(CHANNELS)}") + config = config_manager.load_config() + if not isinstance(config.get('auto_update'), dict): + config['auto_update'] = {} + config['auto_update']['channel'] = channel + config_manager.save_config(config) + return config + + +class ChannelStatus(dict): + """What the channel means for this checkout right now (a JSON-able dict). + + Keys: ``configured`` ('stable', 'beta' or None), ``channel`` (the one in + effect: 'beta' while stable is waiting), ``waiting``, ``migrate`` (write + 'stable' to a config that names no channel), ``action``, ``head``, + ``branch`` ('' when detached), ``newest_release`` and its commit + ``newest_release_sha``, ``current_release`` (the release tag HEAD is + exactly on, if any), ``target_sha`` for ACTION_CHECKOUT_TAG, and + ``message`` for people. + """ + + def __getattr__(self, name): + try: + return self[name] + except KeyError as e: + raise AttributeError(name) from e + + +def _git(project_dir, run, *args, timeout=30): + run = run or subprocess.run + return run(['git', *args], cwd=str(project_dir), capture_output=True, text=True, timeout=timeout) + + +def _out(result): + out = result.stdout + if isinstance(out, bytes): + out = out.decode(errors='replace') + return (out or '').strip() if result.returncode == 0 else '' + + +def fetch(project_dir, run=None, timeout=120): + """Fetch origin's branches and tags. ``--force`` so a moved tag is updated, not an error.""" + return _git(project_dir, run, 'fetch', '--quiet', '--tags', '--force', REMOTE, timeout=timeout) + + +def release_tags(project_dir, run=None): + return [line for line in _out(_git(project_dir, run, 'tag', '--list', 'v*')).splitlines() if line.strip()] + + +def current_branch(project_dir, run=None): + """The checked-out branch, or '' when HEAD is detached.""" + return _out(_git(project_dir, run, 'symbolic-ref', '--quiet', '--short', 'HEAD')) + + +def is_ancestor(project_dir, older, newer, run=None): + """True when ``older`` is ``newer`` or one of its ancestors.""" + return _git(project_dir, run, 'merge-base', '--is-ancestor', older, newer).returncode == 0 + + +def resolve(project_dir, config, run=None): + """Decide what an update on this checkout should do. Reads local refs only: + fetch first (``fetch``) for an answer about what origin has.""" + configured = configured_channel(config) + status = ChannelStatus( + configured=configured, channel='beta', waiting=False, migrate=False, + action=ACTION_PULL, head='', branch=None, newest_release=None, + newest_release_sha='', current_release=None, target_sha='', message='') + + if configured == 'beta': + status['branch'] = current_branch(project_dir, run) + if status.branch: + status['message'] = f'Beta: following {BETA_BRANCH} (the newest code, before it is released).' + else: + status['action'] = ACTION_SWITCH_TO_BETA + status['message'] = (f'Beta: this device is on a release; the next update moves it to ' + f'{BETA_BRANCH}, the newest code.') + return status + + newest = newest_release_tag(release_tags(project_dir, run)) + if newest is None: + # No release to follow (never fetched, or a fork without tags): + # update exactly as before channels existed. + status['waiting'] = True + status['message'] = (f'Stable: no release has been published yet, so updates follow ' + f'{BETA_BRANCH} until one is.') + return status + + head = _out(_git(project_dir, run, 'rev-parse', 'HEAD')) + tag_sha = _out(_git(project_dir, run, 'rev-parse', f'{newest}^{{commit}}')) + status.update(head=head, newest_release=newest, newest_release_sha=tag_sha, + branch=current_branch(project_dir, run)) + if head and head == tag_sha: + status['current_release'] = newest + + if head and tag_sha and is_ancestor(project_dir, head, newest, run): + status['channel'] = 'stable' + status['migrate'] = configured is None + if head == tag_sha: + status['action'] = ACTION_NONE + status['message'] = f'Stable: on the newest release, {newest}.' + else: + status['action'] = ACTION_CHECKOUT_TAG + status['target_sha'] = tag_sha + status['message'] = f'Stable: release {newest} is available.' + return status + + # The newest release does not contain this commit: moving to it would go + # backwards. Keep following the branch until a release that does exists. + status['waiting'] = True + if status.branch: + status['message'] = (f'Stable: this device runs code newer than the newest release ({newest}), ' + f'so it keeps following {BETA_BRANCH} and moves to the first release ' + 'that includes its current version.') + else: + # Detached and newer than the release: there is no branch to follow, + # so stay put until a release catches up. + status['action'] = ACTION_NONE + status['message'] = (f'Stable: this device runs code newer than the newest release ({newest}); ' + 'it stays on it and moves to the first release that includes it.') + return status + + +def checkout(project_dir, args, run=None, timeout=120): + """``git checkout `` that carries uncommitted edits across, like ``pull --autostash``. + + The edits are saved as a stash commit (``git stash create``, which + leaves the stash list alone), the tree is cleaned, the checkout runs, + and the edits are reapplied. If they no longer apply they are kept in + the stash list rather than left half-merged, which is what git's own + autostash does. Returns ``(result, note)``: ``result`` is the checkout's + CompletedProcess, ``note`` a sentence for the user or ''. + """ + stash_sha = _out(_git(project_dir, run, 'stash', 'create', AUTOSTASH_MESSAGE)) + if stash_sha: + cleaned = _git(project_dir, run, 'reset', '--hard', '--quiet', timeout=timeout) + if cleaned.returncode != 0: + return cleaned, '' + result = _git(project_dir, run, 'checkout', '--quiet', *args, timeout=timeout) + note = '' + if stash_sha: + applied = _git(project_dir, run, 'stash', 'apply', '--quiet', stash_sha, timeout=timeout) + if applied.returncode != 0: + _git(project_dir, run, 'reset', '--hard', '--quiet', timeout=timeout) + _git(project_dir, run, 'stash', 'store', '-m', f'{AUTOSTASH_MESSAGE} (did not reapply)', stash_sha) + note = ('Local changes could not be reapplied to the new version and were kept ' + 'in the git stash (git stash list).') + return result, note + + +def checkout_release(project_dir, tag, run=None): + """Move to release ``tag`` (detached HEAD). Refuses a tag that would go backwards.""" + if parse_release_tag(tag) is None: + raise ValueError(f'not a release tag: {tag!r}') + head = _out(_git(project_dir, run, 'rev-parse', 'HEAD')) + if not head or not is_ancestor(project_dir, head, tag, run): + failed = subprocess.CompletedProcess( + ['git', 'checkout', tag], 1, stdout='', + stderr=f'release {tag} does not contain the current commit; refusing to move backwards') + return failed, '' + return checkout(project_dir, ['--detach', f'{tag}^{{commit}}'], run) + + +def checkout_beta_branch(project_dir, run=None): + """Leave a detached release for ``main``, tracking origin/main. The caller then pulls. + + Straight to origin/main when the local branch has nothing of its own + (the usual case: it is wherever the device last left main, often older + than the release it is on). Going through that older commit would make + the carried edits apply to the wrong version, and a plugin file that did + not exist yet would drop them into the stash. A local branch with + commits of its own is checked out as it is and rebased by the pull. + """ + local_ref = f'refs/heads/{BETA_BRANCH}' + remote_ref = f'{REMOTE}/{BETA_BRANCH}' + local = _git(project_dir, run, 'show-ref', '--verify', '--quiet', local_ref).returncode == 0 + if local and not is_ancestor(project_dir, local_ref, remote_ref, run): + result, note = checkout(project_dir, [BETA_BRANCH], run) + else: + # -B: create it, or fast-forward it (it is an ancestor, so nothing is lost). + result, note = checkout(project_dir, ['-B', BETA_BRANCH, remote_ref], run) + if result.returncode == 0: + # A branch left without tracking would make the pull that follows + # take the no-upstream fallback; set it while we are here. + _git(project_dir, run, 'branch', f'--set-upstream-to={REMOTE}/{BETA_BRANCH}', BETA_BRANCH) + return result, note