diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1559e..c857b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,15 @@ for 1.0.0 have something to be assembled from. ## Unreleased -Nothing since 0.9.8. +- The Updates page notices when the update helper on a host was installed for a + **different channel** than the page follows. It is declared in two places — + `lembas.env` and the systemd unit — and only the installer writes both, so + editing one by hand would have left the button deploying something other than + what the page named, with nothing anywhere saying so. +- Fixed: release notes from a **signed** tag rendered the signature block. + `_notes_for` stripped the PGP header only, and which header appears depends on + `gpg.format` — this repository signs with SSH. +- A `CHANGELOG.md`, kept from now on rather than assembled at release time. ## 0.9.8 diff --git a/deploy/install.sh b/deploy/install.sh index 04f9b73..6a38c3a 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -175,7 +175,10 @@ if [[ "$INSTALL_UPDATE_HELPER" == "1" ]]; then done sudo systemctl daemon-reload sudo systemctl enable --now lembas-update.path - sudo touch "$UPDATE_MARKER" + # The channel goes *into* the marker, not just its existence. It is declared + # in two places -- the unit above and lembas.env -- and this is what lets the + # Updates page notice when somebody has edited one and not the other. + echo "$CHANNEL" | sudo tee "$UPDATE_MARKER" >/dev/null sudo chown "$SERVICE_USER:$SERVICE_USER" "$UPDATE_MARKER" echo " installed. The web interface can now deploy the $CHANNEL channel and restart." else diff --git a/src/lembas/services/updates.py b/src/lembas/services/updates.py index 62de453..d664845 100644 --- a/src/lembas/services/updates.py +++ b/src/lembas/services/updates.py @@ -101,6 +101,19 @@ REQUEST_NAME = "update-requested" # Written by `install.sh` when the helper is installed. A marker rather than # asking systemd, because `systemctl is-enabled` means a subprocess on every # page render to answer a question that changes once. +# +# It carries the channel the helper was installed with, because that is declared +# in **two** places -- here for the helper, and `lembas.env` for this process -- +# and `install.sh` writing both together is the only thing keeping them in step. +# Edit one by hand and the page says `stable` while the button deploys `edge`, +# with nothing anywhere disagreeing. Reading it back is what lets the page say +# so. Empty on every host installed before this, which reads as "unknown" rather +# than as a mismatch. +# +# Display only. The service account can write this file, so a compromised +# process could lie about what the helper will do -- but not change it, because +# the helper's own channel is baked into its unit under /etc, which this account +# cannot touch. Lying about the channel is a much smaller thing than choosing it. MARKER_NAME = ".update-helper" # `%H` sha, `%s` subject, `%cI` date, split on a unit separator rather than a @@ -159,9 +172,23 @@ class State: # before the version bump names a release nobody can identify afterwards. version_mismatch: str = "" helper: bool = False + # What the helper will actually deploy, when it said. Empty means it did not + # -- an older install -- and the page then says nothing rather than claiming + # agreement it cannot check. + helper_channel: str = "" requested: bool = False error: str = "" + @property + def channel_mismatch(self) -> bool: + """The page and the helper disagree about what would be deployed. + + Only answerable when the helper said which channel it has. Somebody + edited one of the two places and not the other, and without this the + button would quietly deploy something other than what the page named. + """ + return bool(self.helper_channel) and self.helper_channel != self.channel + @property def is_git(self) -> bool: return bool(self.checkout) @@ -239,6 +266,14 @@ def helper_installed() -> bool: return (settings.data_dir / MARKER_NAME).exists() +def helper_channel() -> str: + """The channel the helper was installed with, or "" if it did not say.""" + try: + return (settings.data_dir / MARKER_NAME).read_text(encoding="utf-8").strip() + except OSError: + return "" + + def request_path() -> Path: return settings.data_dir / REQUEST_NAME @@ -345,6 +380,7 @@ def read(*, fetch: bool = False) -> State: "channel": channel, "branch": branch, "helper": helper_installed(), + "helper_channel": helper_channel(), "requested": pending(), } if root is None: @@ -444,6 +480,7 @@ __all__ = [ "channel_name", "checkout_dir", "clear_request", + "helper_channel", "helper_installed", "manual_command", "pending", diff --git a/src/lembas/web/templates/admin/updates.html b/src/lembas/web/templates/admin/updates.html index b040eac..619ef43 100644 --- a/src/lembas/web/templates/admin/updates.html +++ b/src/lembas/web/templates/admin/updates.html @@ -181,6 +181,25 @@ {% elif state.helper %} + {% if state.channel_mismatch %} + {# + The two declarations have drifted. Worth its own alert rather than a note: + the button would deploy something other than what this page has been naming + all the way down, and nothing else anywhere would say so. + #} +
lembas.env and the systemd unit); re-run the installer with
+ INSTALL_UPDATE_HELPER=1 and the channel you want, which
+ writes both.
+
+ This host has the update helper installed, so the button below writes a request that a systemd unit picks up and runs as root. It always deploys the diff --git a/tests/test_updates.py b/tests/test_updates.py index aa3e1ad..8aeae0e 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -45,8 +45,8 @@ def clean(): updates.clear_request() -def _install_helper(): - (settings.data_dir / updates.MARKER_NAME).touch() +def _install_helper(channel: str = ""): + (settings.data_dir / updates.MARKER_NAME).write_text(channel) # --- Reading the state ---------------------------------------------------------- @@ -265,6 +265,33 @@ def test_an_unknown_channel_falls_back_to_stable(db, monkeypatch): assert updates.channel_name() == updates.CHANNEL_STABLE +def test_a_helper_installed_for_another_channel_is_named(db, client, registered): + """The channel is declared in two places -- lembas.env and the systemd unit -- + and only the installer writes both. Edit one by hand and the button deploys + something other than what the page has been naming all the way down.""" + _install_helper("stable" if updates.channel_name() == "edge" else "edge") + + state = updates.read() + assert state.channel_mismatch is True + assert "would deploy" in client.get("/admin/updates").text + + +def test_a_helper_that_did_not_say_claims_no_disagreement(db): + """Every host installed before the marker carried a channel. Unknown is not + a mismatch, and claiming one would be a red alert on every existing host.""" + _install_helper("") + + assert updates.helper_channel() == "" + assert updates.read().channel_mismatch is False + + +def test_a_matching_helper_says_nothing(db, client, registered): + _install_helper(updates.channel_name()) + + assert updates.read().channel_mismatch is False + assert "would deploy" not in client.get("/admin/updates").text + + # --- The page --------------------------------------------------------------------- def test_the_page_offers_nothing_without_the_helper(db, client, registered): page = client.get("/admin/updates").text