Compare commits

8 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 6946bae455 release: 0.2.6.1, for the canary machine only
Wonder Ink prints — lessons and worksheets, through a preview — without
the 0.2.2 crash; lessons are the size the browser shows them and zoom;
the tab strip looks like the classroom; and four-part versions work end
to end, with a published version never used again.

Published to canary: ekklesia-classroom_0.2.6.1_amd64.deb, sha256
7bd3aabfbbd6f727be87fea366ecaefb40bd5ada37124c96703e8b9ccf510bc3.
Gated on that exact file as an upgrade from 0.2.6 (the laptop) and from
0.2.2 (the desktops). Stable still answers 0.2.2 and nothing here wrote
to it; the three desktops are unchanged until paper comes out of Elmer
and the owner approves — see docs/testing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:59:58 -05:00
ekklesiaandClaude Opus 5.5 259c43bee8 docs: the 0.2.6.1 GUI checks, as the lab's Plasma session showed them
Printing from the real context menu and preview, Cancel, a fullscreen
page and its return, the restart after a hard kill, and a worksheet at
150% lesson size (the PDF viewer keeps its own zoom).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:59:58 -05:00
ekklesiaandClaude Opus 5.5 1d9da3bca0 test: rehearse the stable rooms' upgrade on every canary, and list Sunday's checks
update_canary.sh now also validates the exact package it is about to
publish as an upgrade from whatever stable names — 0.2.2 today, several
releases behind the laptop's canary. A package that upgrades the laptop
and breaks the desktops has to fail before it is published.

The lab print check runs in the Plasma lab as well as a plain container:
its rig lived in /tmp, which is a tmpfs there that docker cp cannot write
into, and its inline-PDF server shared a port with another test server.

docs/testing.md has the physical checks for 0.2.6.1 — paper through
Elmer, the real keyboard's zoom keys, the machine's graphics through
--diagnose, sign-out with a real account — before stable moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:38:04 -05:00
ekklesiaandClaude Opus 5.5 88ea3e86fd feat: measure why lessons load slowly, and give a technician the same tool
Measured before changing anything, and nothing about loading changed,
because the evidence does not point at Wonder Ink. Same lab, same page,
timed by each engine until the sign-in form exists: Wonder Ink 2.86-3.00 s
cold and 2.20-2.49 s warm, Chrome 2.79-3.48 s and 1.96-2.15 s. The site is
the weight — 119-160 requests and 3.2 MB for a sign-in form, and its
console reports loading the Google Maps API twice. Memory about 610 MB
for one tab, 115 MB per further tab; idle CPU about 1%.

The lab draws in software for both engines, so it cannot show the one
likely cause it has no hardware for: QtWebEngine failing to use a
classroom's GPU where Chrome succeeds. classroom-wonderink --diagnose,
run from the maintenance terminal in the teacher's session, prints the
engine versions, the scale, the WebGL renderer (a driver, or software),
and the sign-in page's time from a cold cache, in a throwaway profile
beside any running Wonder Ink.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:23:20 -05:00
ekklesiaandClaude Opus 5.5 1cb9fd35d2 feat: Wonder Ink looks like the classroom, and its preview shows the page
The tab strip is drawn from the classroom's own palette, the one the
panel, Parent Call and Presentations read: larger titles, an accent
under the active tab, worksheets marked with a document icon. Its one
corner widget is the lesson's size and Print — not a new-tab button. With
one tab the strip stays hidden, as before; a change of size shows a
moment's badge instead, and the right-click menu and keys still work.
The window is named "Lesson — Wonder Ink" and carries its own icon.

The print preview showed "Page 1 could not be shown" in the real
session while printing correctly: poppler 25.03 does not write to stdout
when asked for "-" — it writes a file called "-.png" into the current
directory, which for Wonder Ink is the teacher's home. Pages are now
rendered into a directory of their own, and the lab print check fails
any preview that does not draw its page.

The teacher's manual has lesson size and printing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:13:04 -05:00
ekklesiaandClaude Opus 5.5 48b3097c43 feat: a lesson in Wonder Ink is the size the browser shows it, and zooms
Teachers found lessons in Wonder Ink too small next to the ordinary
browser. Measured by asking each page for devicePixelRatio: Wonder Ink
follows the display scale exactly (dpr 1.5 at 150%), and Chrome is
scaled TWICE (dpr 2.25) — under Plasma it follows the compositor itself
and classroom-browser also forces UI_SCALE, a flag from the sway session.

The browser is left as it is. Wonder Ink's pages start at zoom =
UI_SCALE, the room's own number (1.0 at 100%), and measured afterwards
match the browser: dpr 2.25 and 853 px against 2.25 and 854. The window
is not zoomed; Qt scales it once, correctly.

Ctrl+Plus/=/keypad, Ctrl+Minus, Ctrl+0, Ctrl+wheel and the right-click
menu set one level for every tab, remembered as LEVEL= in its own file,
relative to the room. Two actions on one key are ambiguous to Qt and it
then does neither — Ctrl+Minus did nothing until each command became one
action with all its keys, which also fixes Ctrl+Shift+Tab.

The engine profile is built by one function, which the lab's checks now
use: a check with its own profile showed every worksheet blank.

The lab fences 10.10.0.0/16 as well: the development host reaches the
real Elmer over Tailscale, and the lab provisions a queue pointing at it.
Four lab checks written for older code are corrected; one had failed on
every run since 0.2.3.

tools/lab/wonderink-zoom-check: 18 checks. Lab: converged 120, print 39,
worksheets 24, all passing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:04:58 -05:00
ekklesiaandClaude Opus 5.5 10433b850d fix: Wonder Ink prints without crashing, and prints worksheets at all
Little Waders, 0.2.2: SIGSEGV in QPrinter::resolution() every time a
teacher confirmed a print job. QWebEngineView.print() keeps the raw
QPrinter pointer and returns; Qt's didPrintPage() reads
printer->resolution() once Chromium has rendered the page, and by then
PyQt had deleted the printer, which print_view() held only as a local.
Reproduced in the lab with the 0.2.2 code, same backtrace, no job.

Keeping the printer alive fixed the crash and was measured, and Qt's
path was still not a print path: a worksheet PDF tab printed as one
blank page, a lesson went as 24.6 MB of 300 dpi pictures with no text,
and a queue refusing jobs was reported as printed.

Print is now: make a PDF (a worksheet already is one; a lesson page is
printToPdf'd onto the printer's own paper; an inline PDF is downloaded
and opened as a worksheet), show it in a preview with printer, pages,
copies and colour, and hand it to CUPS with lp — Chrome's route in the
same rooms. Nothing asynchronous holds a printer. Black and white uses
the driver's ColorModel=Grayscale, because Elmer's generic PCL 6 driver
ignores print-color-mode.

An exception escaping any slot no longer aborts Wonder Ink: PyQt6's
default is to kill the process, and it is now logged instead.

tests/test-classroom-printing.sh: 50 checks. tools/lab/wonderink-print-
check: 39 checks over 13 scenarios in the lab, against a queue built
from Elmer's PPD, judged by what CUPS received. Not yet on paper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 18:36:29 -05:00
ekklesiaandClaude Opus 5.5 690ef95aff feat: four-part versions, and a published version is never used again
0.2.6.1 is a maintenance release of 0.2.6. The build refused four
parts, update_canary.sh's automatic bump could not add one to "6.1", and
neither publish script would have noticed a version being published
twice — which is what happened to 0.2.6 this morning: it was rebuilt
with the Presentations hardening and uploaded over the first 0.2.6, and
a room that had installed the first can never be offered the second.

One release library now holds the version pattern (three or four parts,
no leading zeros, no .0 maintenance), orders by dpkg --compare-versions,
refuses a version that has ever been published or is not newer than all
of them, uploads with --ignore-existing and checks the server's
checksum, writes a manifest only for a package the server holds, and
reads the channel back over HTTPS afterwards. update_canary.sh takes the
version explicitly; update_stable.sh refuses to go backwards.

The classroom updater refuses a manifest whose version, package name or
checksum is malformed, and refuses a download whose own control file is
not the version the manifest promised, so a room can never loop on
reinstalling and rebooting.

tests/test-release-versions.sh: 87 checks against a stand-in server and
real dpkg. The Trixie gate now also rehearses a stable room: 0.2.2 to an
interim 0.2.6.1 upgraded with settings, channel and Presentations=false
intact, and the installed updater answering correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 18:19:37 -05:00
37 changed files with 3719 additions and 263 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.6
0.2.6.1
+23 -1
View File
@@ -20,10 +20,32 @@ and the **×** on a tab to close it.
There is no address bar, on purpose. Wonder Ink can take you anywhere
the lesson needs to go.
## Making the lesson bigger or smaller
Press **Ctrl** and **+** to make the lesson bigger, **Ctrl** and **−** to
make it smaller, and **Ctrl** and **0** to go back to normal. You can also
right-click the lesson and choose **Make the lesson bigger**. When several
tabs are open, the **− 100% +** buttons at the top right do the same.
The size applies to every tab and is remembered next time.
## Printing
Press **Ctrl** and **P**, click **Print** at the top right, or right-click
and choose **Print…**. You will see the pages exactly as they will print.
* Choose **Only some pages** to print, for example, `2` or `1-3`.
* Set **Copies** for the number of children.
* Choose **Black and white** to save colour ink.
Click **Print**. Wonder Ink says when the printer has it and goes back to
the lesson. **Cancel** prints nothing.
## Worksheets and colouring pages
Click the worksheet link in the lesson. It opens in a new tab, ready to
read or print.
read or print. A worksheet's own **−** and **+** at the top of the page
make it bigger or smaller.
Worksheets you download are kept separately from everything else on the
computer, so they never get mixed up with anything.
+9 -2
View File
@@ -43,7 +43,7 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
<body>
<div class="sheet">
<h1>Classroom manual</h1>
<p class="revision">River of Glory &middot; Revision 22 September 2026</p>
<p class="revision">River of Glory &middot; Revision 23 September 2026</p>
<nav id='contents'></nav>
<section id="00-welcome">
<h2>Your classroom computer</h2>
@@ -131,8 +131,15 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
<ol><li>Click <strong>Wonder Ink</strong> on the bar.</li><li>Sign in with your Wonder Ink account.</li></ol>
<p>A lesson often opens several tabs — the lesson, its video, an activity. They appear in a row across the top. Click a tab to move between them, and the <strong>×</strong> on a tab to close it.</p>
<p>There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.</p>
<h3>Making the lesson bigger or smaller</h3>
<p>Press <strong>Ctrl</strong> and <strong>+</strong> to make the lesson bigger, <strong>Ctrl</strong> and <strong>−</strong> to make it smaller, and <strong>Ctrl</strong> and <strong>0</strong> to go back to normal. You can also right-click the lesson and choose <strong>Make the lesson bigger</strong>. When several tabs are open, the <strong>− 100% +</strong> buttons at the top right do the same.</p>
<p>The size applies to every tab and is remembered next time.</p>
<h3>Printing</h3>
<p>Press <strong>Ctrl</strong> and <strong>P</strong>, click <strong>Print</strong> at the top right, or right-click and choose <strong>Print…</strong>. You will see the pages exactly as they will print.</p>
<ul><li>Choose <strong>Only some pages</strong> to print, for example, `2` or `1-3`.</li><li>Set <strong>Copies</strong> for the number of children.</li><li>Choose <strong>Black and white</strong> to save colour ink.</li></ul>
<p>Click <strong>Print</strong>. Wonder Ink says when the printer has it and goes back to the lesson. <strong>Cancel</strong> prints nothing.</p>
<h3>Worksheets and colouring pages</h3>
<p>Click the worksheet link in the lesson. It opens in a new tab, ready to read or print.</p>
<p>Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. A worksheet&#x27;s own <strong>−</strong> and <strong>+</strong> at the top of the page make it bigger or smaller.</p>
<p>Worksheets you download are kept separately from everything else on the computer, so they never get mixed up with anything.</p>
<h3>When you have finished</h3>
<p><strong>Close Wonder Ink at the end of your session.</strong> Closing it signs you out, so the next teacher in this room starts with their own account and not yours.</p>
+33 -16
View File
@@ -43,7 +43,7 @@ endobj
stream
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET
BT /F1 13 Tf 1 0 0 1 62.0 677.1 Tm (River of Glory) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 22 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 23 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 623.7 Tm (This manual covers every classroom computer in the church. Not every room has every button, so) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 608.8 Tm (some pages begin by saying when they apply. If your computer does not have what a page describes,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 593.9 Tm (that page is not for your room.) Tj ET
@@ -206,7 +206,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 203.4 Tm (Download it again. Your original download
endstream
endobj
20 0 obj
<< /Length 1838 >>
<< /Length 3431 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Wonder Ink) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
@@ -221,13 +221,30 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Sign in with your Wonder Ink account.) Tj
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (A lesson often opens several tabs - the lesson, its video, an activity. They appear in a row) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 599.7 Tm (across the top. Click a tab to move between them, and the x on a tab to close it.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 579.7 Tm (There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 551.8 Tm (Worksheets and colouring pages) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 511.2 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (get mixed up with anything.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 468.4 Tm (When you have finished) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 447.6 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 432.7 Tm (this room starts with their own account and not yours.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 551.8 Tm (Making the lesson bigger or smaller) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Press Ctrl and + to make the lesson bigger, Ctrl and ? to make it smaller, and Ctrl and 0 to go) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (back to normal. You can also right-click the lesson and choose Make the lesson bigger. When) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 501.3 Tm (several tabs are open, the ? 100% + buttons at the top right do the same.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 481.4 Tm (The size applies to every tab and is remembered next time.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 453.4 Tm (Printing) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 432.7 Tm (Press Ctrl and P, click Print at the top right, or right-click and choose Print?. You will see) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 417.8 Tm (the pages exactly as they will print.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 397.9 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 397.9 Tm (Choose Only some pages to print, for example, `2` or `1-3`.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 383.0 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 383.0 Tm (Set Copies for the number of children.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 368.1 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 368.1 Tm (Choose Black and white to save colour ink.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 348.1 Tm (Click Print. Wonder Ink says when the printer has it and goes back to the lesson. Cancel prints) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 333.2 Tm (nothing.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 305.3 Tm (Worksheets and colouring pages) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 284.6 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. A) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 269.7 Tm (worksheet's own ? and + at the top of the page make it bigger or smaller.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 249.8 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 234.8 Tm (get mixed up with anything.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 206.9 Tm (When you have finished) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 186.2 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 171.3 Tm (this room starts with their own account and not yours.) Tj ET
endstream
endobj
21 0 obj
@@ -349,14 +366,14 @@ xref
0000007041 00000 n
0000011178 00000 n
0000014320 00000 n
0000016211 00000 n
0000017739 00000 n
0000019292 00000 n
0000020511 00000 n
0000022330 00000 n
0000022428 00000 n
0000017804 00000 n
0000019332 00000 n
0000020885 00000 n
0000022104 00000 n
0000023923 00000 n
0000024021 00000 n
trailer
<< /Size 27 /Root 1 0 R >>
startxref
22531
24124
%%EOF
+86
View File
@@ -110,3 +110,89 @@ curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
sha256sum c.deb
curl -fsS https://updates.riverofglorymn.org/canary/manifest.json
```
## Version numbers and releasing
```
0.2.6 a feature release
0.2.6.1 the first maintenance release of 0.2.6
0.2.6.2 the second
0.2.7 the next feature release
0.3.0 a larger milestone
```
Three parts for a feature release, four for a maintenance release of one.
The leading zero stays until Classroom is feature-complete; 1.0.0 belongs
to the wider Ekklesia platform's milestone. Nothing else is a version: no
leading zeros (dpkg reads `0.2.06` as `0.2.6`), no `0.2.6.0`, no suffixes.
The build, both publish scripts and every classroom's updater refuse
anything else, from one pattern held to one list of cases by
`tests/test-release-versions.sh`.
Which of two versions is newer is always `dpkg --compare-versions`, never
our own arithmetic, because dpkg is what installs them:
`0.2.2 < 0.2.6 < 0.2.6.1 < 0.2.6.2 < 0.2.6.10 < 0.2.7`.
### A published version is never used again
0.2.6 was rebuilt with later fixes and uploaded a second time under the
same number, replacing the first upload. A room that had installed the
first one could never be offered the second: the updater only installs a
version newer than its own. So now:
* `update_canary.sh VERSION` refuses a version that has ever been
published — on the server or in `repo/packages` — and one that is not
newer than every package ever published. It checks before building and
again after the Trixie gate.
* Packages are uploaded with `rsync --ignore-existing`, and the checksum on
the server is compared with the one built before anything points at it.
* A manifest is only written once the server holds exactly the package
and checksum it names.
* After publishing, the channel is read back over HTTPS the way a classroom
reads it: manifest, versioned package, `latest.deb` and the version dpkg
reads out of the file must all agree.
A publish that fails after the local copy into `repo/packages` has spent
that number. Use the next one.
### Canary
```
./update_canary.sh 0.2.6.1 # a maintenance release
./update_canary.sh 0.2.7 # the next feature release
```
The version is always given. With four parts there are two possible next
numbers, and which one a release is depends on what is in it. Run with no
argument it prints the current canary and both candidates, and builds
nothing.
### Stable
```
./update_stable.sh 0.2.6.1
```
Only with the owner's written approval for that release. Stable is never
given a package of its own: it is pointed at one canary already published,
byte for byte, and refuses a version that is not newer than stable's
current one or whose package the server does not hold with the same
checksum.
### Checking a channel without changing it
```
ROOT=$PWD bash -c 'source tools/release-lib.sh; release_verify_channel stable; release_verify_channel canary'
```
### Rehearsing an upgrade
`tools/validate-trixie-release.sh NEW.deb [PREVIOUS.deb]` installs into a
clean Debian 13 container. `VALIDATION_CHANNEL=stable` rehearses a stable
room, for example the desktops' real path:
```
VALIDATION_CHANNEL=stable tools/validate-trixie-release.sh \
dist/ekklesia-classroom_0.2.6.1_amd64.deb \
repo/packages/ekklesia-classroom_0.2.2_amd64.deb
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+87
View File
@@ -574,3 +574,90 @@ ifupdown stanza is already gone. **Do not run `--adopt` on it.**
- [ ] `nmcli -g 802-11-wireless.hidden connection show "ROGC Classroom"`
still reports `yes`
- [ ] The laptop still reconnects after a reboot
## Wonder Ink — 0.2.6.1, before it goes to stable
Everything below was done in the lab except what a lab cannot do: paper,
a classroom's own graphics, a real keyboard, its network and its screen.
Those are these checks. Stable stays on 0.2.2 until they pass.
Before starting, on each machine: `dpkg-query -W ekklesia-classroom`
must say `0.2.6.1`. The desktops only get it once stable is moved, so on
a desktop install it by hand for the test:
```
curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
sha256sum c.deb # must match .../canary/manifest.json
sudo apt-get install -y ./c.deb && sudo reboot
```
This does **not** move the machine to canary (see provisioning.md): its
next automatic update follows stable again, which is still 0.2.2 — so
after testing, either promote or reinstall 0.2.2 the same way.
### Little Waders — printing through Elmer
- [ ] Open Wonder Ink, sign in, open a lesson with a worksheet
- [ ] Click the worksheet; it opens as a tab named after the file, with a
document icon
- [ ] Ctrl+P (or right-click → Print…): the preview shows the worksheet's
own pages, page count right, ‹ › turn them
- [ ] Printer shows **Elmer**, preselected
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
stays open on the lesson
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
- [ ] Pages `2`, Copies `2`, Black and white → exactly two grey copies of
page 2 come out
- [ ] Print a normal lesson page the same way; it comes out, Letter
- [ ] Open the preview and press Cancel → nothing prints
- [ ] Print three times in a row → three printouts, no crash
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
"printed '"` shows Wonder Ink's line for each
### Size and zoom
- [ ] A lesson in Wonder Ink is the same size as the same page in the
browser on that machine (it was smaller in 0.2.2)
- [ ] **Ctrl and +** (the real keyboard), **Ctrl and −**, **Ctrl and 0**
change it; a "Lesson size" badge appears
- [ ] Open a second tab: it is the same size; the − 100% + and Print
buttons are at the top right
- [ ] Close Wonder Ink and open it again: the size is remembered
- [ ] A worksheet tab's own − / + (in the PDF toolbar) zoom it
- [ ] A lesson video still goes fullscreen and comes back with the tabs
### Loading, and the machine's graphics
From the maintenance terminal (Meta+Ctrl+Alt+Shift+K), in the session:
```
classroom-wonderink --diagnose
```
- [ ] Note the **GPU line**. A driver's name is right. "none … drawing in
software" means QtWebEngine has no GPU on this machine — compare
`chrome://gpu` in the browser; if Chrome has one, that is the cause of
slow lessons and is the next thing to fix
- [ ] Note the sign-in time (the lab: about 2.9 s wired). Much slower here
with a driver named above points at the network
- [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab
### Signing out
- [ ] Sign in, close Wonder Ink, open it again → **signed out**. (Cookies
are session-only and measured so; the site's Local Storage is not
cleared, which is the one way this could fail and has never been
tested with a real account)
### After a reboot, and the other rooms
- [ ] Reboot Little Waders: it comes up as the classroom, Wonder Ink opens
- [ ] On one other desktop, 0.2.6.1 installed: the browser, the DVD player
where there is a drive, Parent Call where enabled, sound through the
television, the panel order — all as on 0.2.2
- [ ] **Presentations is not on the panel** of any desktop
(`grep PRESENTATIONS_ENABLED /etc/ekklesia-classroom/device.conf`
says `false`)
If anything here fails, leave the desktops on stable 0.2.2 and fix it in
0.2.6.2 on canary.
+9 -4
View File
@@ -55,10 +55,15 @@ the display scale for itself asks that command rather than the file:
* **Plasma** — the panel, the clock, notifications, the volume display and
every dialog, because `classroom-scale` sets it as the display's scale
through `kscreen-doctor` at every login.
* **Chrome and Wonder Ink** — `--force-device-scale-factor`, because
Chrome on Wayland was measured not to follow the compositor's output
scale on its own. It is the same number, not a second scaling: Chrome
replaces its detected scale with it.
* **Chrome** — `--force-device-scale-factor`, because Chrome on the sway
session was measured not to follow the compositor's output scale on its
own. **Under Plasma it does**, so this is now a second scaling: measured
at 150%, Chrome's pages report `devicePixelRatio` 2.25, not 1.5. Left as
it is in 0.2.6.1 because it is the size teachers know; see
[wonder-ink.md](wonder-ink.md#how-large-a-lesson-is).
* **Wonder Ink** — its window follows the compositor as every Qt
application does, and its *pages* start at a zoom of `UI_SCALE`, so a
lesson is the size the browser shows it.
* **The disc player's own surfaces** — they are measured in the logical
pixels the compositor is already scaling, so they follow without being
told.
+185 -9
View File
@@ -140,9 +140,8 @@ Alongside it, two Qt defaults are turned off rather than left implicit:
`LocalContentCanAccessFileUrls` (on by default, and would let a local
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
The print dialog's **Print to File** option is also removed. Choosing it
enables an output path with a "..." button, which is a file chooser, in a
room whose whole design is that there is no file chooser.
Printing has no **Print to File** and no file chooser of any kind: the
preview (below) offers the room's CUPS printers and nothing else.
### What a teacher still cannot reach
@@ -182,6 +181,84 @@ is Chrome's business and stays that way.
Downloads folder. That is partly tidiness and mostly the security
argument: it is the only place a worksheet can come from.
## Printing
### The crash (0.2.2, Little Waders)
Wonder Ink closed completely every time a teacher confirmed a print job:
`SIGSEGV in QPrinter::resolution()`, twice, both through QtWebEngine's
print-completion path.
`QWebEngineView.print(printer)` is asynchronous. In Qt 6.8.2
(`qwebengineview.cpp`) it stores the raw `QPrinter*` and returns; when
Chromium has rendered the page, `didPrintPage()` calls
`currentPrinter->resolution()` and hands the printer to a worker thread.
0.2.2 made the printer a local variable in `print_view()`, so PyQt deleted
it when the function returned and Qt read freed memory. Reproduced in the
lab with the 0.2.2 code: `QPrinter::resolution()` ←
`QWebEnginePagePrivate::didPrintPage` ← `WebContentsAdapter::didPrintPage`,
the Little Waders backtrace, and no job in CUPS.
Keeping the printer alive until `printFinished` stops the crash — that was
built and measured first — and it is still not a working print path:
| Measured, QtWebEngine 6.8.2, lab queue built from Elmer's PPD | Qt `view.print()` | `printToPdf()` + `lp` |
| --- | --- | --- |
| A three-page lesson | 24.6 MB of 300 dpi pictures, no text, A4 on a test queue | 18 KB vector PDF with its text, on Letter, 0.43 s through Elmer's filters |
| A worksheet PDF tab | **one blank page** | the worksheet file itself |
| A PDF a site shows inline | — | `printToPdf` never answers (`CreatePreviewDocument got 0 page count`) — so it is downloaded and printed as a file |
| A queue refusing jobs | reported as **printed** | "Refusing did not take the job: Destination … is not accepting jobs." |
| Black and white on Elmer | — | IPP `print-color-mode` is **ignored** by the generic PCL 6 driver ("Unknown option", prints colour); `ColorModel=Grayscale` gives `pxlmono` |
Qt's path also mishandles three things its own source makes plain: a
second `print()` on a page that is still printing is refused with no
`printFinished`, the worker keeps the printer after the tab has gone, and
quitting destroys the printer under that worker.
So 0.2.6.1 does not use Qt printing at all. **Print** makes a PDF — a
worksheet already is one; a lesson page is rendered by the engine onto the
printer's own paper with Chrome-like 10 mm margins — shows it, and hands it
to CUPS with `lp`, which is how Chrome prints in the same rooms. Nothing
asynchronous ever holds a printer, so closing a tab or Wonder Ink cannot
break a job: once `lp` returns, the job is CUPS's.
### The preview
One dialog, for lessons and worksheets alike:
* the document as it will print, a whole page at a time, ‹ › to turn;
* **Printer** — the CUPS destinations, the system default preselected
(Elmer, in every room);
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
document with the reason given if wrong;
* **Copies** — 1 to 50;
* **Colour** — colour or black and white, only where the printer's driver
has a grey mode, and asked for in that driver's own terms;
* **Cancel** sends nothing; **Print** returns to the lesson once CUPS has
the job, and a notification says where it went. A refused job keeps the
preview open with CUPS's reason.
Choosing a printer with different paper lays a lesson page out again. A
page that closes its own tab while the preview is open is reported, not
printed. Print pressed again while a preview is open does nothing, and so
does a page calling `window.print()` meanwhile. The PDF viewer's own print
button opens the same preview.
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
when the preview closes.
`tools/lab/wonderink-print-check` runs all of this against the installed
command in the lab, and judges every scenario by what CUPS actually
received. `tests/test-classroom-printing.sh` covers the CUPS side at every
build. **Neither is paper.** The first print through the real Elmer is a
job for somebody standing next to it.
### Any other error
An exception escaping any Qt handler makes PyQt6 abort the whole process —
measured: a mistake in one slot and the window vanishes. Wonder Ink now
logs it to the journal and carries on.
## What stayed the same
* **A separate profile from the ordinary browser.** A teacher's Wonder Ink
@@ -199,13 +276,112 @@ argument: it is the only place a worksheet can come from.
`~/.local/share/ekklesia-classroom/wonderink`. The new one is
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
intact.
* **One scale for the room.** Chrome has to be handed `UI_SCALE` because
it was measured not to follow the compositor on Wayland. Qt does follow
it — measured at `UI_SCALE=2.0`, where a Qt client on that session
reports `devicePixelRatio 2.0` without being told anything — so Wonder
Ink is deliberately *not* told, or the scale would be applied twice.
* **One scale for the room** — see *How large a lesson is*, below.
## How large a lesson is
Teachers found lessons in Wonder Ink too small next to the same site in
the ordinary browser. Measured in the lab (Plasma 6 on KWin, Debian 13) by
asking each page for `window.devicePixelRatio` and its width in CSS pixels,
with the display scale set the way a room sets it (`kscreen-doctor` and
`UI_SCALE`):
| Display scale | Wonder Ink 0.2.6 | Chrome, as `classroom-browser` starts it |
| --- | --- | --- |
| 100% | dpr 1, 1920 px wide | dpr 1, 1920 px wide |
| 150% | dpr 1.5, 1280 px wide | **dpr 2.25**, 854 px wide |
| 200% | dpr 2, 960 px wide | **dpr 4**, 500 px wide |
Wonder Ink was right by the desktop's measure: it follows the display
scale exactly, like the panel and every dialog. **Chrome is scaled twice.**
Under Plasma it follows the compositor's scale itself, and
`classroom-browser` also forces `--force-device-scale-factor=UI_SCALE` — a
flag from the sway session, where Chrome was measured *not* following the
compositor. So in any room above 100%, every page in the browser is
`UI_SCALE` times larger than the desktop around it, and that is the size
teachers read lessons at.
0.2.6.1 does not change the browser — it is the size the rooms know, and
changing it everywhere is its own decision. Wonder Ink's pages start at
the browser's size instead: **page zoom = `UI_SCALE`**, the room's own
clamped number, which is exactly 1.0 in a room at 100%. Wonder Ink's
window, tabs and dialogs are not zoomed; Qt already scales them once,
correctly. Measured after the change: at 150% the lesson reports dpr 2.25
and 853 px, Chrome dpr 2.25 and 854 px.
**The teacher's own size** goes on top: Ctrl+Plus (and Ctrl+=, and the
keypad's), Ctrl+Minus, Ctrl+0 for normal, Ctrl+wheel, or the right-click
menu. Chrome's steps, 50% to 300% of the room's normal size, one level for
every tab including new ones. It is remembered in
`~/.config/ekklesia-classroom/wonderink.conf` as a single `LEVEL=` line —
its own file, nowhere near the engine profile, so remembering a size can
never mean remembering a sign-in — and relative to the room, so an
administrator changing `UI_SCALE` later still gets the right result.
Measured along the way:
* the engine keeps a tab's zoom across every navigation, same site or not,
so it is set once when the tab is made and never re-applied;
* zoom does not change what prints: the same lesson is 3 pages at 100%,
200% and 400%;
* **page zoom does not reach a worksheet tab.** Chromium's PDF viewer
follows the display scale like the window, fits the page to its width,
and has its own − / + zoom in its toolbar, which is what a teacher uses
there;
* two Qt actions on one key are ambiguous and Qt does *neither* — Ctrl+Minus
did nothing while it was bound once by name and again as KDE's standard
zoom-out key. One action per command now carries all its keys, which
also fixed Ctrl+Shift+Tab (bound twice the same way since 0.2.1).
`tools/lab/wonderink-zoom-check` asks the installed command all of this.
The lab's injected keyboard does not reach its nested session, so the
check delivers keys through Qt's own key path to the focused page; a
physical Ctrl+Plus on a classroom keyboard is a hardware check.
## Why lessons load slowly
Measured before anything was changed, and nothing about loading was
changed, because the evidence does not point at Wonder Ink.
In the lab, on the same connection, the same page, timed by each
engine's own Navigation Timing until the sign-in form exists (the site
is a 3.7 KB shell that builds itself in JavaScript, so its load event is
not the moment a teacher can use it):
| Until the sign-in page is usable | Wonder Ink | Chrome |
| --- | --- | --- |
| cold cache, 3 runs | 2.86 – 3.00 s | 2.79 – 3.48 s |
| warm cache, 3 runs | 2.20 – 2.49 s | 1.96 – 2.15 s |
| window on screen after launch | 0.33 s | 0.42 s |
* **The site is the weight:** 119 – 160 requests and 3.2 MB for a sign-in
form, about 430 KB warm. Its console says it loads the Google Maps
JavaScript API *twice*, synchronously. That is Wonder Ink's to fix.
* Wonder Ink is about a quarter of a second slower warm: it revalidates
the page shell (62 – 87 ms to first byte) where Chrome serves it from
its cache. Not the "very long time" teachers describe.
* Memory: about 610 MB for one tab and 115 MB more for each further tab —
eight tabs, 1.4 GB — one renderer each. Idle CPU about 1%: nothing
spins in the background.
* The network: from the lab, 0.14 – 0.22 s to first byte; from the
development host, 0.42 – 0.44 s. Cloudflare in front, uncached.
What the lab **cannot** show is the machines: it draws in software for
both engines. A classroom whose graphics fail in QtWebEngine but work in
Chrome would look exactly like the complaint. So there is a command for a
technician, run from the maintenance terminal inside the teacher's
session (Meta+Ctrl+Alt+Shift+K):
classroom-wonderink --diagnose
It prints the engine's versions, the scale, **what WebGL reports as the
renderer** — a real driver's name, or "none … drawing in software" — and
times the sign-in page from a cold cache in a throwaway profile, beside a
running Wonder Ink without touching it. Compare its time with the lab's
2.9 s, and its renderer with Chrome's on the same machine (open
`chrome://gpu` there).
## Turning it off
One future device is a laptop that will not have Wonder Ink at all.
+227
View File
@@ -0,0 +1,227 @@
"""Printing a PDF to the room's printer, and showing what will be printed.
Wonder Ink's printing, without Qt's printing in it. Every document Wonder
Ink prints is a PDF by the time it gets here — a worksheet already is
one, and a lesson page is turned into one by the engine — so what is left
is the part the rest of the classroom already trusts: CUPS.
WHY NOT QPRINTER.
Measured in the lab on Debian 13 (QtWebEngine 6.8.2), against a queue
built from Elmer's own PPD with a file in place of the printer:
QWebEngineView.print() a lesson page arrived as 24.6 MB of
300 dpi pictures, no text left in it;
a worksheet PDF tab arrived as ONE BLANK
PAGE; a queue rejecting jobs was reported
as printed successfully; and on 0.2.2 it
crashed, because the printer was deleted
before Qt had finished with it.
printToPdf() + lp the same lesson was an 18 KB PDF with its
text, on Letter, through Elmer's filters
in 0.43 s; a rejected job is an error
with CUPS's own words in it.
The second is what Chrome does, and Chrome is what the rooms already
print from without trouble.
"""
import re
import subprocess
# lp hands the file to the scheduler and returns; it does not wait for
# the printer. A scheduler that has not answered in this long is not
# going to, and a teacher is standing at the dialog.
SUBMIT_TIMEOUT = 30
QUERY_TIMEOUT = 10
# What a worksheet can reasonably be asked for. A spin box that goes to
# 999 is one slip of a finger away from a ream of paper.
MAX_COPIES = 50
# Paper names a driver uses, in the terms Qt's page sizes are named by.
# Anything else is Letter, which is what the church's printer is.
PAPER = {"Letter": "Letter", "A4": "A4", "Legal": "Legal"}
# The driver's own words for "no colour", in the order they are tried.
# Elmer's generic PCL 6 driver ignores the IPP print-color-mode attribute
# altogether — measured, it logs "Unknown option" and prints in colour —
# so black and white has to be asked for in the PPD's terms.
GRAY_CHOICES = ("Grayscale", "Gray", "Mono", "Monochrome", "BlackWhite")
def _run(command, timeout=QUERY_TIMEOUT):
try:
return subprocess.run(
command, capture_output=True, text=True, timeout=timeout
)
except (OSError, subprocess.TimeoutExpired) as error:
return subprocess.CompletedProcess(command, 1, "", str(error))
def printers():
"""(names, default) for every printer CUPS will accept a job for.
lpstat -e is the list a teacher can print to: in a classroom that is
Elmer and nothing else, because discovery is switched off.
"""
names = [
line.strip()
for line in _run(["lpstat", "-e"]).stdout.splitlines()
if line.strip()
]
default = None
match = re.search(
r"system default destination:\s*(\S+)", _run(["lpstat", "-d"]).stdout
)
if match and match.group(1) in names:
default = match.group(1)
elif names:
default = names[0]
return names, default
def options(printer):
"""What this printer's driver offers, parsed from lpoptions -l.
{"paper": "Letter", "gray": ("ColorModel", "Grayscale") or None}
"""
found = {"paper": "Letter", "gray": None}
for line in _run(["lpoptions", "-p", printer, "-l"]).stdout.splitlines():
if ":" not in line:
continue
key, choices = line.split(":", 1)
key = key.split("/", 1)[0].strip()
choices = choices.split()
default = next((c[1:] for c in choices if c.startswith("*")), None)
plain = [c.lstrip("*") for c in choices]
if key == "PageSize" and default in PAPER:
found["paper"] = PAPER[default]
if key in ("ColorModel", "ColorMode", "OutputMode"):
gray = next((c for c in GRAY_CHOICES if c in plain), None)
if gray is not None and found["gray"] is None:
found["gray"] = (key, gray)
return found
def page_count(pdf):
"""How many pages the PDF has, or 0 if it cannot be read."""
result = _run(["pdfinfo", str(pdf)])
match = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE)
return int(match.group(1)) if match else 0
def render_page(pdf, number, width):
"""PNG bytes of one page, WIDTH pixels wide, or None.
Rendered into a directory of its own and read back. Measured on
poppler 25.03: asked for "-" as its output, pdftoppm does NOT write to
stdout — it writes a file called "-.png" (or "--1.png") into whatever
directory it was started in, which for Wonder Ink is the teacher's
home, and the preview showed nothing at all.
"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory(prefix="wonderink-page-") as directory:
root = Path(directory) / "page"
try:
result = subprocess.run(
[
"pdftoppm", "-f", str(number), "-l", str(number),
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
"-png", "-singlefile", "--", str(pdf), str(root),
],
capture_output=True,
timeout=QUERY_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return None
image = root.with_suffix(".png")
if result.returncode != 0 or not image.is_file():
return None
data = image.read_bytes()
return data or None
def parse_pages(text, count):
"""A teacher's page list, as CUPS wants it, or ValueError saying why.
"" or "all" is every page (None). Otherwise numbers and ranges
separated by commas — "2", "1-3", "1, 3, 5-6" — each inside the
document. Returned normalised: "1-3,5".
"""
text = (text or "").strip().lower()
if text in ("", "all"):
return None
parts = []
for piece in re.split(r"\s*,\s*", text):
match = re.fullmatch(r"(\d+)(?:\s*-\s*(\d+))?", piece)
if not match:
raise ValueError(
f"“{piece}” is not a page number. "
"Use numbers like 2 or 1-3, separated by commas."
)
first = int(match.group(1))
last = int(match.group(2) or first)
if first < 1 or last < first:
raise ValueError(f"“{piece}” is not a range of pages.")
if last > count:
raise ValueError(
f"This document has {count} page{'s' if count != 1 else ''}."
)
parts.append(str(first) if first == last else f"{first}-{last}")
return ",".join(parts)
def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
"""Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
Success means the scheduler has the job and its id — which is the
thing Qt's own print path could not say: it reported a rejected job
as printed.
"""
copies = max(1, min(int(copies), MAX_COPIES))
command = ["lp", "-d", printer, "-n", str(copies), "-t", title]
if pages:
command += ["-P", pages]
if gray:
command += ["-o", f"{gray[0]}={gray[1]}"]
command += ["--", str(pdf)]
result = _run(command, timeout=SUBMIT_TIMEOUT)
match = re.search(r"request id is (\S+)", result.stdout)
if result.returncode == 0 and match:
return True, match.group(1)
message = (result.stderr or result.stdout).strip()
# "lp: The printer or class is not accepting jobs." reads better
# without the program's name in front of it.
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
return False, message
File diff suppressed because it is too large Load Diff
+94 -3
View File
@@ -5,6 +5,7 @@ import fcntl
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
@@ -13,6 +14,21 @@ from urllib.parse import urljoin
CONFIG = "/etc/ekklesia-classroom/device.conf"
LOCKFILE = "/run/ekklesia-classroom-update.lock"
CACHE_DIR = "/var/cache/ekklesia-classroom"
PACKAGE = "ekklesia-classroom"
# What a published version looks like: 0.2.6 for a feature release,
# 0.2.6.1 for a maintenance release of it. Anything else in a manifest is
# a mistake on the server, and a classroom does nothing with it. The same
# pattern is in tools/release-lib.sh; tests/test-release-versions.sh holds
# the two to one list of good and bad versions.
#
# Only the SHAPE is decided here. Which of two versions is newer is always
# dpkg's answer, because dpkg is what installs them.
VERSION_PATTERN = re.compile(
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$"
)
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
def log(message):
@@ -50,13 +66,70 @@ def installed_version():
return result.stdout.strip()
def valid_version(version):
return isinstance(version, str) and bool(VERSION_PATTERN.match(version))
def version_is_newer(candidate, current):
result = subprocess.run(
["dpkg", "--compare-versions", candidate, "gt", current]
["dpkg", "--compare-versions", candidate, "gt", current],
stderr=subprocess.DEVNULL,
)
return result.returncode == 0
def manifest_problem(manifest):
"""Why this manifest must not be acted on, or None if it may be.
A manifest names a version, the package file for exactly that
version, and that file's checksum. The package name is checked
against the version, not merely read, so a manifest that says 0.2.6.1
and points at the 0.2.6 file is refused rather than installed and
then offered again at every check.
"""
if not isinstance(manifest, dict):
return "the manifest is not an object"
version = manifest.get("version")
if not valid_version(version):
return f"the manifest names an invalid version: {version!r}"
package = manifest.get("package")
expected = f"/packages/{PACKAGE}_{version}_amd64.deb"
if package != expected:
return f"the manifest names {package!r} for version {version}"
checksum = manifest.get("sha256")
if not isinstance(checksum, str) or not SHA256_PATTERN.match(checksum.lower()):
return f"the manifest's sha256 is not a checksum: {checksum!r}"
return None
def package_identity(path):
"""(Package, Version) as dpkg reads them out of a downloaded file."""
result = subprocess.run(
["dpkg-deb", "--field", path, "Package", "Version"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None, None
fields = {}
for line in result.stdout.splitlines():
if ":" in line:
key, value = line.split(":", 1)
fields[key.strip()] = value.strip()
return fields.get("Package"), fields.get("Version")
def fetch_json(url):
request = urllib.request.Request(
url,
@@ -134,6 +207,12 @@ def main():
log(f"Unable to fetch update manifest: {exc}")
return 1
problem = manifest_problem(manifest)
if problem:
log(f"Ignoring the {channel} manifest: {problem}.")
return 1
candidate = manifest["version"]
log(f"Available version: {candidate}")
@@ -149,10 +228,10 @@ def main():
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
expected_hash = manifest["sha256"].lower()
os.makedirs("/var/cache/ekklesia-classroom", exist_ok=True)
os.makedirs(CACHE_DIR, exist_ok=True)
package_path = os.path.join(
"/var/cache/ekklesia-classroom",
CACHE_DIR,
os.path.basename(manifest["package"]),
)
@@ -177,6 +256,18 @@ def main():
log("SHA-256 verification passed.")
# The checksum proves the file is the one the manifest describes;
# this proves the manifest described the right file. A package
# that installs as some other version would be offered again at
# every check, and installed again, and the room rebooted, for
# ever.
name, version = package_identity(temporary_path)
if name != PACKAGE or version != candidate:
log(f"The package is {name} {version}, not {PACKAGE} {candidate}.")
os.unlink(temporary_path)
return 1
os.replace(temporary_path, package_path)
log(f"Installing {candidate}")
+204
View File
@@ -0,0 +1,204 @@
#!/bin/bash
# Wonder Ink's printing: which printers, which options, what is sent.
#
# CUPS is not on the development host, so lpstat, lpoptions, lp and
# pdfinfo are stand-ins that answer with the output the real ones gave in
# the lab — Elmer's own PPD options included. Whether a job really goes
# through Elmer's filters is tools/lab/wonderink-print-check's question.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bin"
cat > "$WORK/bin/lpstat" <<EOF
#!/bin/bash
[[ -f "$WORK/cups-down" ]] && { echo "lpstat: Scheduler is not running." >&2; exit 1; }
case "\$1" in
-e) cat "$WORK/printers" 2>/dev/null ;;
-d) cat "$WORK/default" 2>/dev/null || echo "no system default destination" ;;
esac
EOF
# lpoptions -p NAME -l, as the real one printed it for Elmer's PPD.
cat > "$WORK/bin/lpoptions" <<EOF
#!/bin/bash
cat "$WORK/options-\$2" 2>/dev/null
EOF
cat > "$WORK/bin/lp" <<EOF
#!/bin/bash
printf '%s\n' "\$@" > "$WORK/lp-args"
if [[ -f "$WORK/lp-refuses" ]]; then
echo 'lp: Destination "Elmer" is not accepting jobs.' >&2
exit 1
fi
echo "request id is Elmer-42 (1 file(s))"
EOF
cat > "$WORK/bin/pdfinfo" <<'EOF'
#!/bin/bash
[[ "$(head -c 5 "$1" 2>/dev/null)" == "%PDF-" ]] || { echo "Syntax Error" >&2; exit 1; }
echo "Producer: test"
echo "Pages: $(grep -c '^page' "$1")"
EOF
chmod +x "$WORK"/bin/*
printf 'Elmer\n' > "$WORK/printers"
echo "system default destination: Elmer" > "$WORK/default"
cat > "$WORK/options-Elmer" <<'EOF'
PrintoutMode/Print Quality: Draft Draft.Gray *Normal Normal.Gray High High.Gray
PageSize/Page Size: Custom.WIDTHxHEIGHT *Letter A4 11x17 A3 A5 B5 Env10 EnvC5 EnvDL EnvISOB5 EnvMonarch Executive Legal
Duplex/Double-Sided Printing: DuplexNoTumble DuplexTumble *None
ColorModel/Color Mode: FromPrintoutMode *Color Grayscale
PrinterResolution/Resolution: *FromPrintoutMode 300x300dpi 600x600dpi 1200x600dpi 1200x1200dpi
EOF
cat > "$WORK/options-Office" <<'EOF'
PageSize/Media Size: Letter *A4 Legal
EOF
PATH="$WORK/bin:$PATH" WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import os
import re
import sys
from pathlib import Path
import classroom_printing as printing
root = Path(sys.argv[1])
work = Path(os.environ["WORK"])
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
def lp_args():
return (work / "lp-args").read_text().splitlines()
print("1. which printers a teacher is offered")
check("Elmer, and Elmer is the default", printing.printers() == (["Elmer"], "Elmer"))
(work / "printers").write_text("Elmer\nOffice\n")
(work / "default").write_text("system default destination: Office\n")
check("the system default is preselected, whichever it is",
printing.printers() == (["Elmer", "Office"], "Office"))
(work / "default").write_text("no system default destination\n")
check("with no default, the first printer",
printing.printers() == (["Elmer", "Office"], "Elmer"))
(work / "default").write_text("system default destination: Gone\n")
check("a default that is not in the list is not offered",
printing.printers() == (["Elmer", "Office"], "Elmer"))
(work / "printers").write_text("")
check("no printers at all is an empty list, not an error",
printing.printers() == ([], None))
(work / "cups-down").touch()
check("and so is CUPS not running", printing.printers() == ([], None))
(work / "cups-down").unlink()
(work / "printers").write_text("Elmer\n")
(work / "default").write_text("system default destination: Elmer\n")
print()
print("2. what each printer's driver offers")
elmer = printing.options("Elmer")
check("Elmer's paper is Letter", elmer["paper"] == "Letter", elmer)
# Measured: this driver ignores print-color-mode and prints in colour.
check("black and white is asked for in the driver's own terms",
elmer["gray"] == ("ColorModel", "Grayscale"), elmer)
office = printing.options("Office")
check("an A4 printer is A4", office["paper"] == "A4", office)
check("a printer with no grey choice offers none", office["gray"] is None, office)
unknown = printing.options("Nobody")
check("a printer that says nothing is Letter, colour only",
unknown == {"paper": "Letter", "gray": None}, unknown)
print()
print("3. a teacher's page list")
for text, expected in (("", None), ("all", None), (" ", None), ("2", "2"),
("1-3", "1-3"), ("1, 3, 5-6", "1,3,5-6"), ("3-3", "3"),
("1 - 2", "1-2"), ("6", "6")):
try:
got = printing.parse_pages(text, 6)
except ValueError as error:
got = f"refused: {error}"
check(f"{text!r} is {expected!r}", got == expected, got)
for text in ("7", "0", "3-1", "1-9", "one", "1;lp -d x", "-2", "1--2", "2,", "1.5"):
try:
printing.parse_pages(text, 6)
check(f"{text!r} is refused", False)
except ValueError as error:
check(f"{text!r} is refused, and says why: {error}", bool(str(error)))
print()
print("4. what is handed to CUPS")
pdf = work / "Colour the Ark.pdf"
pdf.write_text("%PDF-1.4\npage\npage\n")
check("a PDF with two pages has two", printing.page_count(pdf) == 2)
check("something that is not a PDF has none",
printing.page_count(work / "printers") == 0)
ok, answer = printing.submit(pdf, "Elmer")
args = lp_args()
check("a job is accepted, with its id", (ok, answer) == (True, "Elmer-42"), answer)
check("to Elmer, one copy, named for the lesson",
args[:6] == ["-d", "Elmer", "-n", "1", "-t", "Wonder Ink"], args)
check("the file comes after --, so no name can be read as an option",
args[-2:] == ["--", str(pdf)], args)
check("and nothing else is asked for", "-P" not in args and "-o" not in args, args)
printing.submit(pdf, "Elmer", copies=3, pages="2-3", gray=("ColorModel", "Grayscale"),
title="Noah")
args = lp_args()
check("copies, pages and black and white arrive as lp options",
args[args.index("-n") + 1] == "3"
and args[args.index("-P") + 1] == "2-3"
and args[args.index("-o") + 1] == "ColorModel=Grayscale"
and args[args.index("-t") + 1] == "Noah", args)
printing.submit(pdf, "Elmer", copies=500)
check(f"copies are capped at {printing.MAX_COPIES}",
lp_args()[lp_args().index("-n") + 1] == str(printing.MAX_COPIES))
printing.submit(pdf, "Elmer", copies=0)
check("and never fewer than one", lp_args()[lp_args().index("-n") + 1] == "1")
(work / "lp-refuses").touch()
ok, answer = printing.submit(pdf, "Elmer")
check("a refused job is a failure, in CUPS's own words",
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer)
print()
print("5. Wonder Ink prints through this, and only this")
ink = (root / "scripts/classroom-wonderink").read_text()
code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#"))
# The 0.2.2 crash, and the blank worksheet, both came from Qt's print path.
check("no QPrinter", "QPrinter(" not in code and "QPrintDialog" not in code)
check("no QWebEngineView.print()", not re.search(r"\bview\.print\(", code))
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
check("which is given up on if the engine never answers",
"PREPARE_TIMEOUT_MS" in code and "self._timer.start(PREPARE_TIMEOUT_MS)" in code)
check("the preview submits with classroom_printing.submit",
"classroom_printing.submit(" in code)
check("there is no file chooser in it", "QFileDialog" not in code)
check("a worksheet tab is printed from its file",
"self._pdf_views[view] = resolved" in code)
check("an inline PDF is fetched as a file first", "view.page().download(view.url())" in code)
check("an exception in a handler is logged, not fatal",
"sys.excepthook = keep_going" in code)
check("the module is packaged",
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+80 -27
View File
@@ -235,16 +235,36 @@ check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in vali
print()
print("6. canary release gate")
validation = canary.index('"$ROOT/tools/validate-trixie-release.sh"')
package_copy = canary.index('cp "$package_path"')
first_rsync = canary.index("rsync -av")
package_copy = canary.index('cp -n "$package_path"')
first_upload = canary.index("release_upload_package")
check("Trixie validation runs before the repository copy", validation < package_copy)
check("Trixie validation runs before any upload", validation < first_rsync)
check("Trixie validation runs before any upload", validation < first_upload)
# A version is checked before anything is built for it, and again after
# the long gate, immediately before the first upload.
check("a spent version is refused before the build",
canary.index("release_check_new_version") < canary.index("build-release.sh"))
check("and again after the gate",
validation < canary.rindex("release_check_new_version") < first_upload)
check("the stable rooms' upgrade is rehearsed on the same file before publishing",
"VALIDATION_CHANNEL=stable" in canary
and canary.index("VALIDATION_CHANNEL=stable") < first_upload)
check("the published channel is read back afterwards",
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
# The gate checks convergence, not just that the package installs: an
# upgraded room ends on the v2 channel with every setting this release
# has, exactly once, and the command a technician runs is safe to run on
# a machine that is already converged.
check("the gate checks a deliberate channel survives",
"'^CHANNEL=canary$'" in validation_script)
'"^CHANNEL=${channel}\\$"' in validation_script)
check("and that channel can be stable as well as canary",
"VALIDATION_CHANNEL" in validation_script
and "stable|canary)" in validation_script)
check("the gate checks the installed version is the package's own",
"/usr/local/lib/ekklesia-classroom/VERSION" in validation_script)
check("and asks the installed updater about it",
"ekklesia-classroom-update --check" in validation_script)
check("and that an upgrade leaves Presentations off",
"grep -q '^PRESENTATIONS_ENABLED=false$'" in validation_script)
check("and that the experiment's channel comes home",
"'^CHANNEL=stable$'" in validation_script)
check("and that every new setting arrived exactly once",
@@ -288,9 +308,24 @@ check("and asks the one command for it",
# UI_SCALE=2.0: kscreen-doctor reports the output at scale 2, and a Qt
# client on that session reports devicePixelRatio 2.0 without being told
# anything. Forcing it as well would apply the scale twice.
wonderink_code = "\n".join(line for line in wonderink.splitlines()
if not line.lstrip().startswith("#"))
check("Wonder Ink is not told twice",
"force-device-scale-factor" not in wonderink
and "QT_SCALE_FACTOR" not in wonderink)
"force-device-scale-factor" not in wonderink_code
and "QT_SCALE_FACTOR" not in wonderink_code)
# Its PAGES are, because Chrome under Plasma turned out to be scaled twice
# (dpr 2.25 at 150%, measured) and a lesson has to be the size teachers
# read it at in the browser. The window itself is left to Qt.
# A technician's command, never a teacher's: it runs in a throwaway
# profile that something owns (an unowned one crashed it), and it never
# touches the signed-in profile a teacher may have open beside it.
diag = wonderink_code[wonderink_code.index("def diagnose("):wonderink_code.index("def main(")]
check("--diagnose uses an owned, throwaway profile",
"QWebEngineProfile(app)" in diag and "build_profile" not in diag
and '"--diagnose" in sys.argv' in wonderink_code)
check("its pages start at the room's scale, which is the browser's size",
"room_zoom=ui_scale(config)" in wonderink_code
and "self._room_zoom * self._level" in wonderink_code)
check("the clock is sized by the bar, not by a number",
'autoFontAndSize", true' in layout and "fontSize" not in layout)
@@ -325,8 +360,11 @@ check("local documents may not read other local documents",
"LocalContentCanAccessFileUrls, False" in wonderink)
check("nor reach the network",
"LocalContentCanAccessRemoteUrls, False" in wonderink)
check("there is no file chooser behind the print dialog",
"PrintDialogOption.PrintToFile, False" in wonderink)
# Qt's print dialog has a Print to File path with a "..." chooser behind
# it. Wonder Ink no longer uses that dialog: its preview offers CUPS
# printers and nothing else, and no file dialog exists anywhere in it.
check("there is no file chooser behind printing",
"QFileDialog" not in wonderink and "QPrintDialog" not in wonderink)
check("nothing downloaded is executed",
"subprocess" not in wonderink.split("def notify")[0]
and "QDesktopServices" not in wonderink
@@ -357,8 +395,17 @@ check("the Present-view autoplay fix is still there",
"PlaybackRequiresUserGesture, False" in wonderink)
check("fullscreen still returns to a maximised window",
"WindowFullScreen" in wonderink)
# One text box exists, and it is the print preview's page range. Anywhere
# else a text box in this window is an address bar.
preview = wonderink[wonderink.index("class PrintPreview"):wonderink.index("\ndef notify")]
# The tab strip has one corner widget, and it is the lesson's size and
# Print — not the new-tab button a browser puts there.
controls = wonderink[wonderink.index("class LessonControls"):wonderink.index("class ZoomBadge")]
check("still no new-tab button or address bar",
"setCornerWidget" not in wonderink and "QLineEdit" not in wonderink)
wonderink.count("setCornerWidget(") == 1
and "setCornerWidget(self._controls" in wonderink
and "open_tab" not in controls and "QLineEdit" not in controls
and wonderink.count("QLineEdit(") == 1 and "QLineEdit(" in preview)
print()
print("7b. the disc player is decided before a session starts")
@@ -477,27 +524,33 @@ print("7c-iii. the permanent download addresses")
# .../stable/latest.deb and .../canary/latest.deb. One address per channel
# that never changes, and still no second record of which version is
# current: the pointer is a symlink to the package the manifest names.
# Both channels publish through one function, so the order below is the
# order for both.
release_lib = (root / "tools/release-lib.sh").read_text()
publish = release_lib[release_lib.index("release_publish_manifest() {"):]
publish = publish[:publish.index("\n}\n\n")]
publish_code = "\n".join(line for line in publish.splitlines()
if not line.lstrip().startswith("#"))
for channel, script in (("canary", "update_canary.sh"),
("stable", "update_stable.sh")):
text = (root / script).read_text()
code = "\n".join(line for line in text.splitlines()
if not line.lstrip().startswith("#"))
check(f"{script} moves the {channel} pointer",
f"{channel}/latest.deb" in text or "latest.deb" in code)
check(f"{script} points it at the versioned package",
"ln -sfn ../packages/$package_name" in code)
# Atomic, so nothing downloads a half-written pointer, and after the
# manifest, so the pointer can lag it but never lead it — it can never
# offer a package the channel has not published.
check(f"{script} swaps it by rename rather than in place",
"mv -Tf latest.deb.new latest.deb" in code)
# Against the rsync that actually uploads the manifest, not the echo
# at the end that prints its URL.
upload = f"classroom-updates/{channel}/manifest.json"
check(f"{script} moves it after the manifest goes up",
code.index("latest.deb") > code.index(upload))
check(f"{script} refuses to point at a package that is not there",
"test -f ../packages/$package_name" in code)
check(f"{script} publishes {channel} through the shared function",
f"release_publish_manifest {channel} " in text)
check("the pointer is moved to the versioned package",
"ln -sfn ../packages/$name latest.deb.new" in publish_code)
# Atomic, so nothing downloads a half-written pointer, and after the
# manifest, so the pointer can lag it but never lead it — it can never
# offer a package the channel has not published.
check("it is swapped by rename rather than in place",
"mv -Tf latest.deb.new latest.deb" in publish_code)
check("after the manifest goes up",
publish_code.index("latest.deb") > publish_code.index("$channel/manifest.json\""))
check("and refuses to point at a package that is not there",
"test -f ../packages/$name" in publish_code)
check("the manifest is refused unless the server holds exactly that package",
publish_code.index("sha256sum") < publish_code.index("cat >"))
check("a package upload never replaces a published file",
"--ignore-existing" in release_lib)
check("and neither script writes a version anywhere but the manifest",
"latest.json" not in (root / "update_canary.sh").read_text()
+549
View File
@@ -0,0 +1,549 @@
#!/bin/bash
# Three- and four-part versions, through everything that builds, publishes
# and installs a classroom package.
#
# Nothing here reaches the real update server. The server is a directory,
# ssh and rsync are stand-ins that act on it, and it is served over HTTP
# on the loopback interface so that the real updater, the real channel
# check and the real dpkg are the ones being asked.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
SERVER_PID=""
cleanup() {
[[ -z "$SERVER_PID" ]] || kill "$SERVER_PID" 2>/dev/null
rm -rf "$WORK"
}
trap cleanup EXIT
passed=0
failed=0
check() {
local label="$1"
shift
if "$@" >/dev/null 2>&1; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
failed=$((failed + 1))
fi
}
refuses() {
! "$@"
}
# ---------------------------------------------------------------------------
# A package that is nothing but its version, the way dpkg will read it.
fake_deb() {
local version="$1" out="$2" stage="$WORK/stage-$version"
mkdir -p "$stage/DEBIAN"
cat > "$stage/DEBIAN/control" <<EOF
Package: ${3:-ekklesia-classroom}
Version: $version
Architecture: amd64
Maintainer: test
Description: test package
EOF
dpkg-deb --root-owner-group --build "$stage" "$out" >/dev/null
}
# ---------------------------------------------------------------------------
# The update server, as a directory.
SERVER="$WORK/server"
LOCAL="$WORK/local"
mkdir -p "$SERVER/packages" "$SERVER/stable" "$SERVER/canary" \
"$LOCAL/packages" "$LOCAL/canary" "$LOCAL/stable" "$WORK/bin"
for version in 0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6; do
fake_deb "$version" "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb"
cp "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb" "$LOCAL/packages/"
done
# Not a classroom package, and on the real server all the same.
echo v2 > "$SERVER/packages/ekklesia-classroom-v2.deb"
manifest() {
local version="$1" file="${2:-ekklesia-classroom_$1_amd64.deb}" hash
hash="$(sha256sum "$SERVER/packages/$file" | awk '{print $1}')"
printf '{\n "version": "%s",\n "package": "/packages/%s",\n "sha256": "%s",\n "requires_reboot": true\n}\n' \
"$version" "$file" "$hash"
}
manifest 0.2.2 > "$SERVER/stable/manifest.json"
manifest 0.2.6 > "$SERVER/canary/manifest.json"
ln -s ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb"
ln -s ../packages/ekklesia-classroom_0.2.6_amd64.deb "$SERVER/canary/latest.deb"
cp "$SERVER/stable/manifest.json" "$LOCAL/stable/"
cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
# ssh HOST COMMAND: run COMMAND here. The release scripts only ever talk
# to one host, and it is this directory.
cat > "$WORK/bin/ssh" <<'EOF'
#!/bin/bash
shift
exec bash -c "$*"
EOF
# rsync [-av] [--ignore-existing] -e ssh SOURCE HOST:DEST
cat > "$WORK/bin/rsync" <<'EOF'
#!/bin/bash
keep=0
args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--ignore-existing) keep=1 ;;
-e) shift ;;
-*) ;;
*) args+=("$1") ;;
esac
shift
done
source="${args[0]}"
dest="${args[1]#*:}"
[[ -d "$dest" ]] && dest="$dest/$(basename "$source")"
if [[ "$keep" == 1 && -e "$dest" ]]; then
exit 0
fi
cp "$source" "$dest"
EOF
chmod +x "$WORK/bin/ssh" "$WORK/bin/rsync"
PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')"
python3 -m http.server --bind 127.0.0.1 --directory "$SERVER" "$PORT" \
>/dev/null 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 50); do
curl -fsS "http://127.0.0.1:$PORT/stable/manifest.json" >/dev/null 2>&1 && break
sleep 0.1
done
echo "0. the library, sourced the way the release scripts source it"
# With nothing in the environment and set -u in force. Every test below
# exports its own RELEASE_* values, and a default that only worked when
# somebody had already set it once stopped the real build dead.
check "it loads under set -u with nothing set" \
env -i PATH="$PATH" bash -c "set -euo pipefail; ROOT=/checkout; source '$ROOT/tools/release-lib.sh'
[[ \$RELEASE_HOST == classroom-control ]]
[[ \$RELEASE_ROOT == /srv/ekklesia/classroom-updates ]]
[[ \$RELEASE_URL == https://updates.riverofglorymn.org ]]
[[ \$RELEASE_LOCAL == /checkout/repo ]]"
check "and build-release.sh gets as far as checking the version" \
bash -c "'$ROOT/tools/build-release.sh' 0.2.06 2>&1 | grep -q 'Invalid version: 0.2.06'"
echo
export PATH="$WORK/bin:$PATH"
export RELEASE_HOST=test-server RELEASE_ROOT="$SERVER" \
RELEASE_URL="http://127.0.0.1:$PORT" RELEASE_LOCAL="$LOCAL"
# shellcheck source=../tools/release-lib.sh
source "$ROOT/tools/release-lib.sh"
GOOD=(0.2.2 0.2.6 0.2.6.1 0.2.6.2 0.2.7 0.3.0 0.2.10 0.2.6.10 1.0.0 10.20.30.40)
BAD=("" 0.2 0.2.6.1.1 0.2.06 00.2.6 0.2.6.0 0.2.6.01 v0.2.6 0.2.6-1 0.2.6~rc1
"0.2.6 " " 0.2.6" 0.2.6. .0.2.6 0..2 0.2.a "0.2.6;reboot" "0.2.6
0.2.7" 0.2.6+1 -0.2.6)
echo "1. what a version may look like"
for version in "${GOOD[@]}"; do
check "the release scripts accept $version" release_version_valid "$version"
done
for version in "${BAD[@]}"; do
check "the release scripts refuse '${version//$'\n'/\\n}'" refuses release_version_valid "$version"
done
# The updater decides with its own copy of the pattern, because it runs
# on a classroom that has no checkout. The two copies are held to the
# same list.
python3 - "$ROOT/scripts/ekklesia-classroom-update" "${GOOD[@]}" -- "${BAD[@]}" <<'PY'
import importlib.machinery
import importlib.util
import sys
loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1])
spec = importlib.util.spec_from_loader("updater", loader)
updater = importlib.util.module_from_spec(spec)
loader.exec_module(updater)
rest = sys.argv[2:]
split = rest.index("--")
good, bad = rest[:split], rest[split + 1:]
wrong = [v for v in good if not updater.valid_version(v)]
wrong += [v for v in bad if updater.valid_version(v)]
wrong += [v for v in (None, 26, 0.26, ["0.2.6"]) if updater.valid_version(v)]
for version in wrong:
print(f" FAIL the updater disagrees about {version!r}")
sys.exit(1 if wrong else 0)
PY
check "the updater agrees about every one of them, and refuses non-strings" test $? -eq 0
echo
echo "2. which version is newer, as dpkg decides it"
ordered=(0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6 0.2.6.1 0.2.6.2 0.2.6.10 0.2.7 0.2.10 0.3.0)
for ((i = 1; i < ${#ordered[@]}; i++)); do
check "${ordered[i-1]} < ${ordered[i]}" \
release_version_gt "${ordered[i]}" "${ordered[i-1]}"
check "and not the other way round" \
refuses release_version_gt "${ordered[i-1]}" "${ordered[i]}"
done
check "0.2.6.1 is not newer than itself" refuses release_version_gt 0.2.6.1 0.2.6.1
check "the highest of everything published is 0.2.6" \
test "$(release_published_versions | release_highest)" = 0.2.6
echo
echo "3. a version may be published once, and only if it is newer"
check "0.2.6.1 may be published" release_check_new_version 0.2.6.1
check "0.2.7 may be published" release_check_new_version 0.2.7
check "0.2.6 may not: it is already on the server" refuses release_check_new_version 0.2.6
check "0.2.5.1 may not: it is older than 0.2.6" refuses release_check_new_version 0.2.5.1
check "0.2.2 may not" refuses release_check_new_version 0.2.2
check "0.2.06 may not: dpkg would call it 0.2.6" refuses release_check_new_version 0.2.06
check "0.2.6.0 may not" refuses release_check_new_version 0.2.6.0
# Published only in the local copy — an upload that failed after the copy
# was made. The number is spent all the same.
fake_deb 0.2.8 "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb"
check "a version already copied into repo/ is spent too" refuses release_check_new_version 0.2.8
rm "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb"
echo
echo "4. uploading never replaces a published package"
fake_deb 0.2.6.1 "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb"
check "0.2.6.1 uploads and arrives intact" \
release_upload_package "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb"
check "and the server copy is the built one" \
cmp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" \
"$SERVER/packages/ekklesia-classroom_0.2.6.1_amd64.deb"
before="$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")"
mkdir -p "$WORK/rebuilt"
fake_deb 0.2.6 "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb" ekklesia-classroom-rebuilt
check "a different 0.2.6 is refused" \
refuses release_upload_package "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb"
check "and the published 0.2.6 is untouched" \
test "$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")" = "$before"
echo
echo "5. a channel points at exactly what was uploaded"
hash_0261="$(sha256sum "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')"
stable_before="$(cat "$SERVER/stable/manifest.json")"
check "a manifest promising a different checksum is refused" \
refuses release_publish_manifest canary 0.2.6.1 "$(printf '0%.0s' {1..64})"
check "and canary still says 0.2.6" test "$(release_channel_version canary)" = 0.2.6
check "canary is moved to 0.2.6.1" release_publish_manifest canary 0.2.6.1 "$hash_0261"
check "its manifest says so" test "$(release_channel_version canary)" = 0.2.6.1
check "latest.deb follows it" \
test "$(readlink "$SERVER/canary/latest.deb")" = ../packages/ekklesia-classroom_0.2.6.1_amd64.deb
check "what a classroom downloads agrees, over HTTP" release_verify_channel canary 0.2.6.1
check "stable's manifest was not touched" \
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
check "and stable still verifies as 0.2.2" release_verify_channel stable 0.2.2
check "the check notices a channel saying the wrong version" \
refuses release_verify_channel stable 0.2.6.1
check "0.2.6.1 is now spent" refuses release_check_new_version 0.2.6.1
check "0.2.6.2 is next in line" release_check_new_version 0.2.6.2
# A package on the server that says it is a different version from its
# name. The channel check has to read the package, not just its name.
fake_deb 0.2.6 "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb"
manifest 0.2.9 > "$SERVER/canary/manifest.json"
ln -sfn ../packages/ekklesia-classroom_0.2.9_amd64.deb "$SERVER/canary/latest.deb"
check "a package that says it is another version fails the channel check" \
refuses release_verify_channel canary 0.2.9
rm "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb"
manifest 0.2.6.1 > "$SERVER/canary/manifest.json"
ln -sfn ../packages/ekklesia-classroom_0.2.6.1_amd64.deb "$SERVER/canary/latest.deb"
echo
echo "6. update_stable.sh"
cp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" "$LOCAL/packages/"
check "refuses a version that is not newer than stable" \
refuses "$ROOT/update_stable.sh" 0.2.2
check "refuses a malformed version" refuses "$ROOT/update_stable.sh" 0.2.06
fake_deb 0.2.9 "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
check "refuses a package that was built but never uploaded" \
refuses "$ROOT/update_stable.sh" 0.2.9
rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
check "stable is still 0.2.2 after all of that" \
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
echo
echo "7. update_canary.sh"
check "without a version it builds nothing and names both choices" \
bash -c "'$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.6.2 .*maintenance' \
&& '$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.7 .*feature' \
&& ! '$ROOT/update_canary.sh'"
check "a published version is refused before anything is built" \
bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'"
check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1
# ---------------------------------------------------------------------------
echo
echo "8. a classroom's updater, against that server"
# The package database and apt, as far as the updater can tell. The
# stand-in apt installs by reading the version out of the file it is
# given, which is what the real one does.
cat > "$WORK/bin/dpkg-query" <<EOF
#!/bin/bash
[[ -s "$WORK/installed" ]] || exit 1
cat "$WORK/installed"
EOF
cat > "$WORK/bin/apt-get" <<EOF
#!/bin/bash
echo "\$*" >> "$WORK/apt.log"
dpkg-deb --field "\${@: -1}" Version > "$WORK/installed"
EOF
cat > "$WORK/bin/systemctl" <<EOF
#!/bin/bash
echo "\$*" >> "$WORK/systemctl.log"
EOF
chmod +x "$WORK/bin/dpkg-query" "$WORK/bin/apt-get" "$WORK/bin/systemctl"
UPDATE_URL="http://127.0.0.1:$PORT" WORK="$WORK" SERVER="$SERVER" \
python3 - "$ROOT/scripts/ekklesia-classroom-update" <<'PY'
import contextlib
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
work = Path(os.environ["WORK"])
server = Path(os.environ["SERVER"])
loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1])
spec = importlib.util.spec_from_loader("updater", loader)
updater = importlib.util.module_from_spec(spec)
loader.exec_module(updater)
updater.LOCKFILE = str(work / "update.lock")
updater.CACHE_DIR = str(work / "cache")
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
def device(channel, installed):
config = work / f"device-{channel}.conf"
config.write_text(
"# a classroom\n"
f"UPDATE_URL={os.environ['UPDATE_URL']}\n"
f"CHANNEL={channel}\n"
"PRESENTATIONS_ENABLED=false\n"
"UI_SCALE=1.25\n"
)
updater.CONFIG = str(config)
(work / "installed").write_text(installed)
for log in ("apt.log", "systemctl.log"):
(work / log).unlink(missing_ok=True)
return config
def run(*args):
sys.argv = ["ekklesia-classroom-update", *args]
out = io.StringIO()
with contextlib.redirect_stdout(out):
status = updater.main()
return status, out.getvalue()
def installs():
log = work / "apt.log"
return log.read_text().splitlines() if log.exists() else []
def installed():
return (work / "installed").read_text().strip()
def deb(version, name=None, package="ekklesia-classroom"):
stage = work / f"stage-{version}-{package}"
(stage / "DEBIAN").mkdir(parents=True, exist_ok=True)
(stage / "DEBIAN" / "control").write_text(
f"Package: {package}\nVersion: {version}\nArchitecture: amd64\n"
"Maintainer: test\nDescription: test\n"
)
out = server / "packages" / (name or f"ekklesia-classroom_{version}_amd64.deb")
out.unlink(missing_ok=True)
subprocess.run(["dpkg-deb", "--root-owner-group", "--build", stage, out],
check=True, capture_output=True)
return out
def publish(channel, version, file=None, sha=None, raw=None):
path = server / channel / "manifest.json"
if raw is not None:
path.write_text(raw)
return
file = file or f"ekklesia-classroom_{version}_amd64.deb"
digest = sha or hashlib.sha256((server / "packages" / file).read_bytes()).hexdigest()
path.write_text(json.dumps({
"version": version, "package": f"/packages/{file}",
"sha256": digest, "requires_reboot": True,
}))
publish("stable", "0.2.2")
publish("canary", "0.2.6.1")
config = device("canary", "0.2.6")
before = config.read_text()
status, out = run("--check")
check("a canary device on 0.2.6 sees 0.2.6.1 as an update",
status == 0 and "Update available: 0.2.6 -> 0.2.6.1" in out, out)
check("and --check installs nothing", installs() == [])
status, out = run("--no-reboot")
check("it installs 0.2.6.1", status == 0 and installed() == "0.2.6.1", out)
check("once, from the versioned file",
len(installs()) == 1 and installs()[0].endswith("ekklesia-classroom_0.2.6.1_amd64.deb"),
installs())
check("--no-reboot is respected", not (work / "systemctl.log").exists())
check("its device.conf is exactly as it was", config.read_text() == before)
for attempt in range(3):
run("--no-reboot")
check("a device on 0.2.6.1 does not install 0.2.6.1 again",
len(installs()) == 1, installs())
status, out = run()
check("nor reboot for it", "Already up to date" in out
and not (work / "systemctl.log").exists(), out)
deb("0.2.7")
publish("canary", "0.2.7")
status, out = run()
check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it",
status == 0 and installed() == "0.2.7", out)
check("and reboots, because the manifest asks it to",
(work / "systemctl.log").read_text().strip() == "reboot")
publish("canary", "0.2.6.1")
device("canary", "0.2.7")
status, out = run()
check("a device on 0.2.7 is not moved back to 0.2.6.1",
"Already up to date" in out and installs() == [], out)
# Three parts, exactly as before.
publish("canary", "0.2.6")
device("canary", "0.2.5")
status, out = run("--no-reboot")
check("a device on 0.2.5 still updates to 0.2.6", installed() == "0.2.6", out)
publish("canary", "0.2.6.1")
# The channels.
config = device("stable", "0.2.2")
status, out = run("--no-reboot")
check("a stable device on 0.2.2 reads the stable manifest",
f"{os.environ['UPDATE_URL']}/stable/manifest.json" in out, out)
check("and does not install the canary release",
installs() == [] and installed() == "0.2.2" and "Already up to date" in out, out)
deb("0.2.6.2")
publish("stable", "0.2.6.2")
status, out = run("--no-reboot")
check("a stable device goes from 0.2.2 straight to a four-part release when stable has one",
installed() == "0.2.6.2", out)
publish("stable", "0.2.2")
(server / "packages" / "ekklesia-classroom_0.2.6.2_amd64.deb").unlink()
# A room with no package installed at all reports 0.0.0 and takes anything.
device("canary", "")
status, out = run("--check")
check("a machine with nothing installed is offered 0.2.6.1",
"0.0.0 -> 0.2.6.1" in out, out)
# Malformed manifests: nothing downloaded, nothing installed, and a
# failure the timer's journal will show.
sha = hashlib.sha256((server / "packages" / "ekklesia-classroom_0.2.6.1_amd64.deb").read_bytes()).hexdigest()
bad = {
"a version with a suffix": {"version": "0.2.6.1-1"},
"a version with five parts": {"version": "0.2.6.1.1"},
"a version with a leading zero": {"version": "0.2.06"},
"a version that is a number": {"version": 0.3},
"an empty version": {"version": ""},
"a version with a shell in it": {"version": "0.2.7;reboot"},
"no version at all": {"version": None},
"a package for a different version": {"version": "0.2.7",
"package": "/packages/ekklesia-classroom_0.2.6.1_amd64.deb"},
"a package outside /packages": {"version": "0.2.7",
"package": "/../ekklesia-classroom_0.2.7_amd64.deb"},
"a checksum that is not one": {"version": "0.2.7", "sha256": "abc"},
}
for label, fields in bad.items():
manifest = {"version": "0.2.7",
"package": "/packages/ekklesia-classroom_0.2.7_amd64.deb",
"sha256": sha, "requires_reboot": True}
manifest.update(fields)
if manifest["version"] is None:
del manifest["version"]
publish("canary", None, raw=json.dumps(manifest))
device("canary", "0.2.6")
status, out = run("--no-reboot")
check(f"refused: {label}",
status == 1 and installs() == [] and installed() == "0.2.6", out)
for label, raw in (("a manifest that is not JSON", "<html>502</html>"),
("a manifest that is a list", "[]")):
publish("canary", None, raw=raw)
device("canary", "0.2.6")
status, out = run("--no-reboot")
check(f"refused: {label}", status == 1 and installs() == [], out)
# The file arrives, matches its checksum, and is the wrong package.
shutil.rmtree(updater.CACHE_DIR, ignore_errors=True)
deb("0.2.6", name="ekklesia-classroom_0.2.7_amd64.deb")
publish("canary", "0.2.7")
device("canary", "0.2.6")
status, out = run("--no-reboot")
check("a 0.2.7 file that installs as 0.2.6 is refused",
status == 1 and installs() == [] and "not ekklesia-classroom 0.2.7" in out, out)
check("and not left in the cache",
not list(Path(updater.CACHE_DIR).glob("*0.2.7*")))
deb("0.2.7", package="something-else", name="ekklesia-classroom_0.2.7_amd64.deb")
publish("canary", "0.2.7")
status, out = run("--no-reboot")
check("so is a file that is some other package", status == 1 and installs() == [], out)
deb("0.2.7")
publish("canary", "0.2.7", sha="0" * 64)
status, out = run("--no-reboot")
check("and a file whose checksum is wrong",
status == 1 and installs() == [] and "verification FAILED" in out, out)
publish("canary", "0.2.6.1")
print(f"\n updater: {passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
check "every updater case above passed" test $? -eq 0
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+19 -5
View File
@@ -11,11 +11,10 @@ fi
VERSION="$1"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version: $VERSION"
echo "Expected MAJOR.MINOR.PATCH, for example 0.1.6"
exit 1
fi
# Three parts for a feature release, four for a maintenance release of
# one: 0.2.6, then 0.2.6.1. See tools/release-lib.sh.
source "$ROOT/tools/release-lib.sh"
release_require_version "$VERSION" || exit 1
STAGE="$ROOT/packaging/root"
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
@@ -384,6 +383,11 @@ install -Dm755 \
"$ROOT/scripts/classroom-wonderink" \
"$STAGE/usr/local/bin/classroom-wonderink"
# Wonder Ink's printing: a PDF, shown, then handed to CUPS with lp.
install -Dm644 \
"$ROOT/lib/classroom_printing.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_printing.py"
# PRESENTATIONS.
#
# The library a teacher sees, and the watcher that fills it from their
@@ -568,6 +572,16 @@ dpkg-deb \
--root-owner-group \
--build "$STAGE" "$OUTPUT"
# The version dpkg reads out of the file is the one a classroom will
# report, so it is checked rather than assumed to have survived the sed.
built_version="$(release_package_version "$OUTPUT")"
if [[ "$built_version" != "$VERSION" ]]; then
echo "The package says it is $built_version, not $VERSION" >&2
rm -f "$OUTPUT"
exit 1
fi
echo
echo "Built:"
echo "$OUTPUT"
+15 -7
View File
@@ -302,8 +302,11 @@ check "the launcher names the shell's app id" ekklesia-wonderink \
# Comments stripped first. The shell has a comment saying Ctrl+T is
# deliberately absent, and a check that cannot tell that from a binding
# is a check that fails for being right.
# One text box exists, in the print preview, for the page range. The
# check reads the code outside that class; anywhere else a text box in
# this window would be an address bar.
check "no code offers a new tab button or an address bar" 0 \
"$(in_lab "grep -vE '^[[:space:]]*#' /usr/local/bin/classroom-wonderink | grep -cE 'QLineEdit|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")"
"$(in_lab "sed '/^class PrintPreview/,/^def notify/d; /setCornerWidget(self._controls/d' /usr/local/bin/classroom-wonderink | grep -vE '^[[:space:]]*#' | grep -cE 'QLineEdit\\(|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")"
# Separate from the ordinary browser, which is the whole reason the split
# exists: a teacher's Wonder Ink sign-in and the room's shared YouTube
# account must never be in one profile.
@@ -329,7 +332,7 @@ check "and the browser is untouched by any of this" 0 \
section "a lesson's worksheets come back as tabs"
check "worksheets have a directory of Wonder Ink's own" 1 \
"$(in_lab 'grep -c "\"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')"
"$(in_lab 'grep -c ") / \"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')"
check "a finished PDF becomes a tab" 1 \
"$(in_lab 'grep -c "def open_worksheet" /usr/local/bin/classroom-wonderink || true')"
check "checked on its contents, not its name" 1 \
@@ -344,8 +347,10 @@ check "and a guard consults it on every navigation" 1 \
"$(in_lab 'grep -c "def acceptNavigationRequest" /usr/local/bin/classroom-wonderink || true')"
check "a local document cannot read other local documents" 1 \
"$(in_lab 'grep -c "LocalContentCanAccessFileUrls, False" /usr/local/bin/classroom-wonderink || true')"
check "no file chooser behind the print dialog" 1 \
"$(in_lab 'grep -c "PrintDialogOption.PrintToFile, False" /usr/local/bin/classroom-wonderink || true')"
# Qt's print dialog, and its Print to File chooser, are gone altogether:
# the preview offers CUPS printers and nothing else.
check "no file chooser behind printing" 0 \
"$(in_lab 'grep -cE "QFileDialog|QPrintDialog" /usr/local/bin/classroom-wonderink || true')"
check "nothing downloaded is handed to the desktop to run" 0 \
"$(in_lab 'grep -cE "QDesktopServices|xdg-open" /usr/local/bin/classroom-wonderink || true')"
check "and no file browser was added" 0 \
@@ -365,10 +370,13 @@ check "device.conf carries the setting" 1 \
"$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')"
check "and it defaults to on" true \
"$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')"
# One line the setting switches, since 0.2.3's one-setting-per-application
# layout: var WONDERINK = true|false. This asked for the older marker
# until 0.2.6.1 and so failed on every lab run in between.
check "the layout carries a marker the setting can switch" 1 \
"$(in_lab 'grep -c "CLASSROOM-WONDERINK-LAUNCHER \*/ \"applications:ekklesia-wonderink.desktop\"" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
check "and it is switched on for this machine" 0 \
"$(in_lab 'grep -c "// /\* CLASSROOM-WONDERINK-LAUNCHER" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
"$(in_lab 'grep -cE "^var WONDERINK = (true|false);" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
check "and it is switched on for this machine" 1 \
"$(in_lab 'grep -c "^var WONDERINK = true;" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
section "the room's own parts survived"
check "parent call installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-parent-call 2>/dev/null | wc -l')"
+9 -4
View File
@@ -90,11 +90,16 @@ up)
# Container-local: this netns belongs to the container alone and
# nothing on the host or the church network is changed by it. Debian's
# own mirrors are untouched, so installs still work.
# 10.10 is the printers. The package provisions Elmer at
# socket://10.10.0.155:9100, and the development host reaches that
# address over Tailscale, so an unfenced lab that printed would have
# put paper in the church's real printer.
#
# A blackhole route rather than a firewall rule: the lab image has no
# iptables, and the rig's guard had been quietly doing nothing because
# of it. Routes need no netfilter modules and cannot silently fail.
docker exec "$NAME" sh -c '
for net in 10.30.0.0/16 10.40.0.0/16; do
for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
ip route replace blackhole "$net" || exit 1
done' || echo " WARNING: could not fence the church networks"
@@ -102,7 +107,7 @@ up)
# bash feature: under dash it fails whatever the network is doing, so
# the check passed on a lab that could reach the broker perfectly
# well. Measured.
for target in 10.40.0.2/1883 10.30.0.2/1025; do
for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
exit 1
@@ -140,11 +145,11 @@ reboot)
# named after container ids on the real broker, from sessions that
# had been fenced when they started and were not after a reboot.
docker exec "$NAME" sh -c '
for net in 10.30.0.0/16 10.40.0.0/16; do
for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
ip route replace blackhole "$net" || exit 1
done' || echo " WARNING: could not fence the church networks"
for target in 10.40.0.2/1883 10.30.0.2/1025; do
for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
exit 1
+172
View File
@@ -0,0 +1,172 @@
#!/bin/bash
# Does Wonder Ink print — and does a job really reach CUPS?
#
# The lab's CUPS gets an "Elmer" built from Elmer's own PPD and options,
# writing to a file instead of socket://10.10.0.155:9100, so every job
# goes through the same filters the real printer's jobs do and none can
# reach a printer. A second queue refuses jobs, to show a teacher is told.
#
# Each scenario runs the INSTALLED classroom-wonderink under a real
# QtWebEngine (offscreen), presses the preview's buttons, and is then
# judged by CUPS's own record of what it was given.
#
# tools/lab/wonderink-print-check [SCENARIO...]
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
lab() { docker exec -i "$NAME" bash -c "$1"; }
# Not /tmp: in the Plasma lab /tmp is a tmpfs, and docker cp writes into
# the layer underneath it, where nothing can see the file. Measured — the
# first run there "failed" every scenario for exactly that reason.
RIG=/opt/wonderink-check/wonderink-print-rig.py
lab 'mkdir -p /opt/wonderink-check'
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null
lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || {
echo "could not put the rig in the lab" >&2
exit 1
}
# The church's printer network is fenced off inside the lab. Checked, not
# assumed: a lab job must never be able to reach the real Elmer.
lab 'ip route replace blackhole 10.10.0.0/16 2>/dev/null
! timeout 3 bash -c "exec 3<>/dev/tcp/10.10.0.155/9100" 2>/dev/null' || {
echo "REFUSING TO CONTINUE: the real Elmer is reachable from the lab" >&2
exit 1
}
lab '
set -e
export DEBIAN_FRONTEND=noninteractive
command -v lpadmin >/dev/null && command -v pdftoppm >/dev/null || \
apt-get install -y -qq cups cups-client poppler-utils >/dev/null 2>&1
dpkg -s foomatic-db-compressed-ppds >/dev/null 2>&1 || \
apt-get install -y -qq foomatic-db-compressed-ppds cups-filters ghostscript >/dev/null 2>&1
grep -q "^FileDevice Yes" /etc/cups/cups-files.conf || echo "FileDevice Yes" >> /etc/cups/cups-files.conf
pkill -x cupsd || true; sleep 1; /usr/sbin/cupsd; sleep 1
cupsctl --debug-logging
: > /var/log/cups/error_log
mkdir -p /var/lab-print && chown lp /var/lab-print
driver="foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd"
for q in $(lpstat -e 2>/dev/null); do lpadmin -x "$q"; done
lpadmin -p Elmer -E -v file:///var/lab-print/elmer.pxl -m "$driver" \
-D "LAB STAND-IN for Elmer: its PPD, a file for a printer" \
-o PrintoutMode=Normal -o ColorModel=Color 2>/dev/null
lpadmin -d Elmer
lpadmin -p Refusing -E -v file:///dev/null -m "$driver" -D "LAB: refuses jobs" 2>/dev/null
cupsreject Refusing
id teacher >/dev/null 2>&1 || useradd -m teacher
install -d -o teacher -m 700 /tmp/teacher-rt
# An inline PDF, served the way a site shows one rather than sends it. Its
# own port and directory: a lab often has another test server on 8088.
mkdir -p /opt/wonderink-check/www
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
# Started detached. A background process started inside a docker exec
# dies with it in the Plasma lab, whose PID 1 is systemd.
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089
passed=0
failed=0
judge() {
if eval "$2"; then
printf ' \033[32mPASS\033[0m %s\n' "$1"
passed=$((passed + 1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"
failed=$((failed + 1))
fi
}
# Runs one scenario; leaves its output in $out and the jobs it caused in $jobs.
run() {
local before
before=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
out=$(lab "su teacher -c 'cd /tmp; XDG_RUNTIME_DIR=/tmp/teacher-rt QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS=\"--no-sandbox --disable-gpu\" \
timeout 120 python3 $RIG $COMMAND $1 2>&1'; echo \"exit=\$?\"")
sleep 3
jobs=$(lab "lpstat -W all -o | tail -n +$((before + 1)) | awk '{print \$1}' | tr '\n' ' '")
job_options() { lab "grep -E '\\[Job ${1#*-}\\] argv\\[(4|5)\\]' /var/log/cups/error_log"; }
job_device() { lab "grep -E '\\[Job ${1#*-}\\]' /var/log/cups/error_log | grep -oE 'sDEVICE=[a-z]+' | sort -u"; }
}
count() { wc -w <<< "$jobs"; }
said() { grep -q -- "$1" <<< "$out"; }
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat)
# The inline scenario needs a PDF to serve; the worksheet scenario writes one.
lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; }
for scenario in "${scenarios[@]}"; do
echo "$scenario"
run "$scenario"
judge "Wonder Ink did not crash, and exited by itself" exited_cleanly
# The preview has to SHOW the page, not merely count it. A preview
# that printed the right thing while showing a blank was what the
# first version of this check let through.
if said 'PREVIEW '; then
judge "the preview drew the page it will print" "! grep 'PREVIEW ' <<< \"\$out\" | grep -q 'shown=False'"
fi
case "$scenario" in
web)
judge "the preview showed the lesson, 3 Letter pages, Elmer chosen" \
"said \"pages=3 printers=\\['Elmer', 'Refusing'\\] paper=Letter\""
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
judge "Elmer's filters printed it in colour" '[[ "$(job_device $jobs)" == *pxlcolor* ]]'
judge "and nothing was left behind" "said 'scratch_left=\\[\\]'" ;;
pdf|pdf-viewer-button)
judge "the preview showed the worksheet's own 2 pages" "said 'pages=2'"
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
pdf-options)
judge "one job" '[[ $(count) -eq 1 ]]'
judge "page 2 only, three copies" \
'[[ "$(job_options $jobs)" == *"argv[4]=\"3\""* && "$(job_options $jobs)" == *page-ranges=2* ]]'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
inline)
judge "the inline PDF was recognised" "said \"mime='application/pdf'\""
judge "and opened as a worksheet tab" "said 'current_is_pdf=True'"
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
options)
judge "pages 2-3, two copies, black and white" \
'[[ "$(job_options $jobs)" == *page-ranges=2-3* && "$(job_options $jobs)" == *"argv[4]=\"2\""* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
bad-range)
judge "a page that does not exist is explained, not printed" "said 'This document has 3 pages'"
judge "and once corrected, one job" '[[ $(count) -eq 1 ]]' ;;
broken)
judge "a refusing printer is reported in CUPS's words" "said 'Refusing did not take the job'"
judge "and no job is claimed" '[[ $(count) -eq 0 ]] && ! said "NOTIFY Sent"' ;;
cancel)
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]' ;;
page-closes-itself)
judge "the preview says the page closed, and cannot print" \
"said \"status='The page was closed.' print_enabled=False\""
judge "and sends nothing" '[[ $(count) -eq 0 ]]' ;;
close-window)
judge "closing Wonder Ink with the preview open sends nothing" '[[ $(count) -eq 0 ]]' ;;
page-prints-again)
judge "a second print request does not open a second preview" "said 'still one preview=True'"
judge "one job" '[[ $(count) -eq 1 ]]' ;;
repeat)
judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;;
esac
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
done
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+141
View File
@@ -0,0 +1,141 @@
"""Drive Wonder Ink's print preview through one scenario, headless.
usage: wonderink-print-rig.py WONDERINK_COMMAND SCENARIO
Run by tools/lab/wonderink-print-check inside the lab, against the
command the package installed. It presses the preview's own buttons the
way a teacher would, and prints what happened; the check around it asks
CUPS what actually arrived.
"""
import importlib.machinery, importlib.util, sys, time, faulthandler, gc, os
faulthandler.enable()
path, scenario = sys.argv[1], sys.argv[2]
loader = importlib.machinery.SourceFileLoader("wonderink", path)
spec = importlib.util.spec_from_loader("wonderink", loader)
wi = importlib.util.module_from_spec(spec); loader.exec_module(wi)
from pathlib import Path
from PyQt6.QtCore import QTimer, QUrl
from PyQt6.QtGui import QPdfWriter, QPainter, QFont, QPageSize
from PyQt6.QtWidgets import QApplication
from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings
notes = []
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
app = QApplication(sys.argv)
profile = QWebEngineProfile("rig", app)
for a in ("PdfViewerEnabled", "PluginsEnabled"):
profile.settings().setAttribute(getattr(QWebEngineSettings.WebAttribute, a), True)
window = wi.WonderInk(profile)
profile.downloadRequested.connect(window.on_download)
window.resize(1280, 800); window.show()
t0 = time.monotonic()
def stamp(m): print(f"[{time.monotonic()-t0:6.2f}s] {m}", flush=True)
def lesson(view, title="Noahs Ark lesson"):
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
f"<h1>{title}, part {i}</h1><p>{'Noah built an ark. ' * 80}</p>" for i in range(5)
) + "</body></html>", QUrl("https://app.wonderink.org/lesson"))
def worksheet(name="Colour the Ark"):
d = wi.DOWNLOADS; d.mkdir(parents=True, exist_ok=True)
p = d / f"{name}.pdf"
w = QPdfWriter(str(p)); w.setPageSize(QPageSize(QPageSize.PageSizeId.Letter)); w.setResolution(300)
pa = QPainter(w); pa.setFont(QFont("DejaVu Sans", 28))
pa.drawText(200, 400, f"WORKSHEET {name} page one"); pa.drawRect(200, 600, 2000, 1500)
w.newPage(); pa.drawText(200, 400, f"WORKSHEET {name} page two"); pa.end()
return p
def when(cond, then, label, limit=30):
start = time.monotonic()
def poll():
try: ok = cond()
except Exception as e: ok = False
if ok: return then()
if time.monotonic() - start > limit:
stamp(f"GAVE UP waiting for {label}"); app.exit(4); return
QTimer.singleShot(100, poll)
QTimer.singleShot(100, poll)
def preview(): return window._preview
def ready(): return preview() is not None and preview()._pdf is not None
def describe(d):
return f"shown={d._page in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
def finish(code=0):
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
QTimer.singleShot(1500, lambda: app.exit(code))
def press_print(then=None):
d = preview(); stamp("PREVIEW " + describe(d))
d._print.click()
stamp(f"after Print: open={preview() is not None} status={d._status.text() if preview() else ''!r}")
(then or finish)()
actions = {
"web": lambda view: when(ready, press_print, "preview"),
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
"options": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), press_print()), "preview"),
"bad-range": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(),
stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"),
preview()._range.setText("1"), press_print()), "preview"),
"broken": lambda view: when(ready, lambda: (
preview()._printer.setCurrentText("Refusing"),
stamp(f"switched to Refusing: paper={preview().paper()} status={preview()._status.text()!r}"),
when(ready, lambda: (
stamp(f"re-rendered on {preview().paper()}: pages={preview()._pages}"),
preview()._print.click(),
stamp(f"broken: open={preview() is not None} status={preview()._status.text()!r}"),
preview()._cancel.click(), finish()), "re-render")), "preview"),
"page-closes-itself": lambda view: when(ready, lambda: (
view.page().runJavaScript("window.close()"),
when(lambda: preview() and "closed" in preview()._status.text(), lambda: (
stamp(f"after page closed: status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()}"),
preview()._cancel.click(), finish()), "closed message")), "preview"),
"close-window": lambda view: when(ready, lambda: (stamp("closing window with preview open"), window.close()), "preview"),
"page-prints-again": lambda view: when(ready, lambda: (
view.page().runJavaScript("window.print()"),
QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"),
}
if scenario == "repeat":
view = window.open_tab(); lesson(view); n = [0]
def once():
if n[0] == 5: return finish()
n[0] += 1; QTimer.singleShot(0, lambda: window.print_view(view))
when(ready, lambda: press_print(then=lambda: QTimer.singleShot(200, once)), "preview")
view.loadFinished.connect(lambda ok: once() if n[0] == 0 else None)
elif scenario in ("pdf", "pdf-options"):
window.open_pdf(worksheet()); view = window.current()
if scenario == "pdf-options":
actions["pdf-options"] = lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), press_print()), "preview")
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
actions[scenario if scenario == "pdf-options" else "web"](view)))
elif scenario == "pdf-viewer-button":
# The viewer's own print button reaches the page as a print request.
window.open_pdf(worksheet()); view = window.current()
QTimer.singleShot(2500, lambda: (actions["web"](view), view.page().printRequested.emit()))
elif scenario == "inline":
view = window.open_tab(); view.load(QUrl("http://127.0.0.1:8089/ark.pdf"))
QTimer.singleShot(4000, lambda: (stamp(f"inline mime={window._mime.get(view)!r} shows_pdf={window._shows_pdf(view)}"),
QTimer.singleShot(0, lambda: window.print_view(view)),
when(ready, lambda: (stamp(f"worksheet tab opened: tabs={window._tabs.count()} current_is_pdf={window.current() in window._pdf_views}"), press_print()), "inline preview")))
else:
if scenario == "page-closes-itself":
lesson(window.open_tab(), "The other lesson")
view = window.open_tab(); lesson(view)
fired = []
def go(ok):
if fired: return
fired.append(1); QTimer.singleShot(0, lambda: window.print_view(view)); actions[scenario](view)
view.loadFinished.connect(go)
QTimer.singleShot(90000, lambda: (stamp("TIMEOUT"), app.exit(3)))
rc = app.exec()
stamp(f"EXIT {rc} scratch_left={sorted(p.name for p in Path(os.environ.get('XDG_RUNTIME_DIR','/tmp')).glob('wonderink-print-*'))}")
sys.exit(rc)
+158
View File
@@ -0,0 +1,158 @@
#!/bin/bash
# How large is a lesson in Wonder Ink, and do the keys change it?
#
# Runs the INSTALLED classroom-wonderink under a real QtWebEngine
# (offscreen), builds its window the way main() does, and asks the page
# itself what it is rendering at. Keys are delivered through Qt's own key
# path to the focused page — the same shortcut map a keyboard reaches —
# because the lab's injected keyboard does not reach the nested session.
# A physical Ctrl+Plus is a hardware check.
#
# tools/lab/wonderink-zoom-check
set -uo pipefail
NAME="${LAB_NAME:-classroom-v2-lab}"
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
docker exec -i \
-e QT_QPA_PLATFORM=offscreen \
-e QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" \
-e COMMAND="$COMMAND" \
"$NAME" bash -c 'export XDG_RUNTIME_DIR=$(mktemp -d) HOME=$(mktemp -d); python3 -' <<'PY'
import importlib.util, os, sys
from importlib.machinery import SourceFileLoader
loader = SourceFileLoader("wonderink", os.environ["COMMAND"])
spec = importlib.util.spec_from_loader("wonderink", loader)
ink = importlib.util.module_from_spec(spec)
loader.exec_module(ink)
from PyQt6.QtCore import QEventLoop, Qt, QTimer
from PyQt6.QtTest import QTest
from PyQt6.QtWebEngineCore import QWebEngineProfile
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv[:1])
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" \033[32mPASS\033[0m {label}")
passed += 1
else:
print(f" \033[31mFAIL\033[0m {label}" + (f" [{detail}]" if detail else ""))
failed += 1
def wait(ms):
loop = QEventLoop()
QTimer.singleShot(ms, loop.quit)
loop.exec()
def page_dpr(view):
answer = []
view.page().runJavaScript("window.devicePixelRatio", answer.append)
for _ in range(50):
if answer:
return answer[0]
wait(100)
return None
PAGE = "data:text/html,<title>lesson</title><p>Noah built an ark.</p>"
KEY = {
"Ctrl+=": (Qt.Key.Key_Equal, Qt.KeyboardModifier.ControlModifier),
"Ctrl+Plus": (Qt.Key.Key_Plus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier),
"keypad Ctrl+Plus": (Qt.Key.Key_Plus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.KeypadModifier),
"Ctrl+Minus": (Qt.Key.Key_Minus, Qt.KeyboardModifier.ControlModifier),
"keypad Ctrl+Minus": (Qt.Key.Key_Minus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.KeypadModifier),
"Ctrl+0": (Qt.Key.Key_0, Qt.KeyboardModifier.ControlModifier),
"Ctrl+Shift+Tab": (Qt.Key.Key_Backtab, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier),
}
def press(window, name):
target = QApplication.focusWidget() or window.current()
QTest.keyClick(target, *KEY[name])
wait(400)
def open_window(room_zoom):
profile = QWebEngineProfile(f"zoomcheck{room_zoom}", app)
window = ink.WonderInk(profile, room_zoom=room_zoom)
window.resize(1280, 800)
window.show()
for _ in range(3):
window.open_tab(PAGE)
wait(2500)
window.current().setFocus()
wait(300)
return window
def factors(window):
return [round(window._tabs.widget(i).zoomFactor(), 3) for i in range(window._tabs.count())]
print("1. a room's normal size is the browser's")
for room in (1.0, 1.5, 2.0):
ink.SETTINGS.unlink(missing_ok=True)
window = open_window(room)
check(f"UI_SCALE {room}: every tab starts at {room}", factors(window) == [room] * 3, factors(window))
window.close()
wait(300)
print()
print("2. the keys a teacher knows, at UI_SCALE 1.5")
ink.SETTINGS.unlink(missing_ok=True)
window = open_window(1.5)
for name, level in (("Ctrl+=", 1.1), ("Ctrl+Plus", 1.25), ("keypad Ctrl+Plus", 1.5),
("Ctrl+Minus", 1.25), ("keypad Ctrl+Minus", 1.1), ("Ctrl+0", 1.0)):
press(window, name)
check(f"{name} makes it {round(level * 100)}% in every tab",
window.level() == level and factors(window) == [round(1.5 * level, 3)] * 3,
f"level={window.level()} factors={factors(window)}")
press(window, "Ctrl+Plus")
dpr = page_dpr(window.current())
check("and the page itself renders at it", dpr is not None and abs(dpr - 1.65) < 0.01, dpr)
print()
print("3. new tabs, and the next time Wonder Ink opens")
view = window.open_tab(PAGE)
wait(1500)
check("a new tab starts at the teacher's size", round(view.zoomFactor(), 3) == 1.65, view.zoomFactor())
check("the level is saved on its own", ink.SETTINGS.read_text().strip() == "LEVEL=1.1",
ink.SETTINGS.read_text())
check("in its own file, not the web profile",
"wonderink-shell" not in str(ink.SETTINGS) and ink.SETTINGS.name == "wonderink.conf")
index = window._tabs.currentIndex()
press(window, "Ctrl+Shift+Tab")
check("Ctrl+Shift+Tab still goes back a tab", window._tabs.currentIndex() == index - 1,
f"{index} -> {window._tabs.currentIndex()}")
window.close()
wait(300)
window = open_window(2.0)
check("reopened in a room at 2.0, the teacher's 110% is kept",
window.level() == 1.1 and factors(window) == [2.2] * 3, factors(window))
window.close()
ink.SETTINGS.write_text("LEVEL=banana\n")
check("a damaged setting is the normal size", ink.load_level() == 1.0)
ink.SETTINGS.write_text("LEVEL=40\n")
check("an absurd one too", ink.load_level() == 1.0)
window = open_window(3.0)
press(window, "keypad Ctrl+Plus"); press(window, "keypad Ctrl+Plus"); press(window, "keypad Ctrl+Plus")
check("a large room zoomed further never passes the engine's limit",
max(factors(window)) <= 5.0, factors(window))
window.close()
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+286
View File
@@ -0,0 +1,286 @@
# Release helpers shared by tools/build-release.sh, update_canary.sh and
# update_stable.sh. Sourced, not run.
#
# VERSIONS.
#
# 0.2.6 a feature release
# 0.2.6.1 the first maintenance release of 0.2.6
# 0.2.6.2 the second
# 0.2.7 the next feature release
#
# Three or four dot-separated numbers, and nothing else. No leading zeros,
# because dpkg compares 0.2.06 and 0.2.6 as the SAME version, and two
# packages that dpkg thinks are equal are one package too many. No fourth
# part of 0: maintenance releases count from 1, and 0.2.6.0 would sort
# after 0.2.6 while claiming to be it.
#
# Ordering is dpkg's, always, through `dpkg --compare-versions`. That is
# what a classroom's updater and apt use to decide what is newer, so
# anything else here could only ever disagree with them.
#
# PUBLISHED IS PERMANENT.
#
# 0.2.6 was rebuilt and uploaded a second time under the same version, and
# the second upload replaced the first on the server. A room that had
# installed the first one could never be offered the second: the updater
# only installs a version newer than its own, and they were equal. So a
# version number, once it has been on the server, is never used again,
# and a package file on the server is never written twice.
RELEASE_HOST="${RELEASE_HOST:-classroom-control}"
RELEASE_ROOT="${RELEASE_ROOT:-/srv/ekklesia/classroom-updates}"
RELEASE_URL="${RELEASE_URL:-https://updates.riverofglorymn.org}"
# The local copy of what was published. ROOT is the checkout, set by
# whichever script sourced this.
RELEASE_LOCAL="${RELEASE_LOCAL:-$ROOT/repo}"
# The same pattern is in scripts/ekklesia-classroom-update, which is what
# a classroom checks a manifest against. tests/test-release-versions.sh
# holds both to the same list of good and bad versions.
RELEASE_VERSION_PATTERN='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$'
release_version_valid() {
[[ "$1" =~ $RELEASE_VERSION_PATTERN ]]
}
release_require_version() {
if ! release_version_valid "$1"; then
echo "Invalid version: $1" >&2
echo "Expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH.MAINTENANCE," >&2
echo "for example 0.2.7 or 0.2.6.1" >&2
return 1
fi
}
release_version_gt() {
dpkg --compare-versions "$1" gt "$2"
}
release_package_name() {
printf 'ekklesia-classroom_%s_amd64.deb\n' "$1"
}
# The version dpkg itself reads out of a built package, which is the one a
# classroom will report after installing it.
release_package_version() {
dpkg-deb --field "$1" Version
}
release_remote() {
ssh "$RELEASE_HOST" "$@"
}
# Every version that has ever been published, one per line, read from the
# package file names on the server and in the local copy of it. Names that
# are not a classroom package (ekklesia-classroom-v2.deb) are skipped;
# experiment builds such as 0.1.45~hypr1 are kept, because dpkg orders
# them and they were published.
release_published_versions() {
{
release_remote "ls -1 '$RELEASE_ROOT/packages'" || return 1
ls -1 "$RELEASE_LOCAL/packages" 2>/dev/null || true
} | sed -n 's/^ekklesia-classroom_\(.*\)_amd64\.deb$/\1/p' | sort -u
}
release_highest() {
local highest="" version
while read -r version; do
[[ -n "$version" ]] || continue
if [[ -z "$highest" ]] || release_version_gt "$version" "$highest"; then
highest="$version"
fi
done
printf '%s\n' "$highest"
}
# The version a channel's manifest on the server names, or nothing.
release_channel_version() {
release_remote "cat '$RELEASE_ROOT/$1/manifest.json' 2>/dev/null" \
| python3 -c 'import json, sys
try:
print(json.load(sys.stdin)["version"])
except Exception:
pass'
}
# May VERSION be published as a new package at all? It has to be valid,
# its file name must never have been used, and it must be newer than every
# package ever published, so that it is newer than whatever any room is
# running whichever channel that room is on.
release_check_new_version() {
local version="$1" name published highest
release_require_version "$version" || return 1
name="$(release_package_name "$version")"
published="$(release_published_versions)" || {
echo "Could not list the published packages on $RELEASE_HOST." >&2
return 1
}
if grep -qxF -- "$version" <<< "$published"; then
echo "$version has already been published." >&2
echo "A published version is never rebuilt or replaced. Use the next one." >&2
return 1
fi
if release_remote "test -e '$RELEASE_ROOT/packages/$name'"; then
echo "$name already exists on $RELEASE_HOST." >&2
return 1
fi
highest="$(release_highest <<< "$published")"
if [[ -n "$highest" ]] && ! release_version_gt "$version" "$highest"; then
echo "$version is not newer than $highest, the highest version already published." >&2
echo "Every release must sort after every one before it." >&2
return 1
fi
}
# Upload a package that has never been on the server, and prove that what
# arrived is what was built. --ignore-existing is the second lock on the
# rule above: even if the check were wrong, rsync would not replace a
# published file.
release_upload_package() {
local package="$1" name local_hash remote_hash
name="$(basename "$package")"
local_hash="$(sha256sum "$package" | awk '{print $1}')"
rsync -av --ignore-existing -e ssh \
"$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/"
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
if [[ "$remote_hash" != "$local_hash" ]]; then
echo "The package on $RELEASE_HOST does not match the one built here." >&2
echo " built: $local_hash" >&2
echo " on server: ${remote_hash:-missing}" >&2
return 1
fi
}
# Write CHANNEL's manifest for VERSION locally, then upload it, then move
# the channel's latest.deb. The package must already be on the server with
# exactly the checksum the manifest is about to promise.
release_publish_manifest() {
local channel="$1" version="$2" hash="$3" name remote_hash
name="$(release_package_name "$version")"
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
if [[ "$remote_hash" != "$hash" ]]; then
echo "Refusing to point $channel at $name:" >&2
echo " the manifest would say $hash" >&2
echo " the server has ${remote_hash:-no such file}" >&2
return 1
fi
mkdir -p "$RELEASE_LOCAL/$channel"
cat > "$RELEASE_LOCAL/$channel/manifest.json" <<EOF
{
"version": "$version",
"package": "/packages/$name",
"sha256": "$hash",
"requires_reboot": true
}
EOF
# Manifest deliberately goes LAST, after the package it names.
rsync -av -e ssh \
"$RELEASE_LOCAL/$channel/manifest.json" \
"$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json"
# THE PERMANENT URL.
#
# .../CHANNEL/latest.deb is a symlink beside the manifest pointing at
# the versioned package the manifest names, so there is one download
# address that never changes and still no second place recording which
# version is current. The manifest remains the only answer to that
# question.
#
# It is swapped AFTER the manifest, and by rename, for two reasons. A
# rename is atomic, so nothing ever downloads a half-written pointer;
# and going last means latest.deb can only ever lag the manifest by a
# moment, never lead it — it can never offer a package the channel has
# not published. The versioned file itself is never touched again once
# uploaded.
release_remote "
set -e
cd '$RELEASE_ROOT/$channel'
test -f ../packages/$name
ln -sfn ../packages/$name latest.deb.new
mv -Tf latest.deb.new latest.deb
"
}
# What a classroom on CHANNEL actually sees, read over HTTPS the way the
# updater reads it: the manifest, the versioned package it names, and the
# permanent latest.deb, all agreeing with each other and with EXPECTED if
# one is given. Read-only; safe to run against stable at any time.
#
# A subshell, so the temporary directory is removed however it returns.
release_verify_channel() (
channel="$1"
expected="${2:-}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
manifest="$(curl -fsS -H 'Cache-Control: no-cache' \
"$RELEASE_URL/$channel/manifest.json")" || {
echo " FAIL $channel: manifest could not be fetched" >&2
return 1
}
read -r version package hash < <(python3 -c 'import json, sys
m = json.loads(sys.argv[1])
print(m["version"], m["package"], m["sha256"])' "$manifest")
name="$(release_package_name "$version")"
if [[ -n "$expected" && "$version" != "$expected" ]]; then
echo " FAIL $channel: manifest says $version, expected $expected" >&2
return 1
fi
if [[ "$package" != "/packages/$name" ]]; then
echo " FAIL $channel: manifest names $package for version $version" >&2
return 1
fi
curl -fsS -o "$work/versioned.deb" "$RELEASE_URL$package" || {
echo " FAIL $channel: $package could not be downloaded" >&2
return 1
}
curl -fsSL -H 'Cache-Control: no-cache' -o "$work/latest.deb" \
"$RELEASE_URL/$channel/latest.deb" || {
echo " FAIL $channel: latest.deb could not be downloaded" >&2
return 1
}
got_hash="$(sha256sum "$work/versioned.deb" | awk '{print $1}')"
latest_hash="$(sha256sum "$work/latest.deb" | awk '{print $1}')"
deb_version="$(release_package_version "$work/versioned.deb")"
if [[ "$got_hash" != "$hash" ]]; then
echo " FAIL $channel: $name has sha256 $got_hash, manifest says $hash" >&2
return 1
fi
if [[ "$latest_hash" != "$hash" ]]; then
echo " FAIL $channel: latest.deb has sha256 $latest_hash, manifest says $hash" >&2
return 1
fi
if [[ "$deb_version" != "$version" ]]; then
echo " FAIL $channel: the package itself says $deb_version, manifest says $version" >&2
return 1
fi
echo " ok $channel: $version, $name, sha256 $hash, latest.deb agrees"
)
+102 -5
View File
@@ -18,6 +18,16 @@ if [[ $# -eq 2 ]]; then
[[ -f "$previous" ]] || { echo "Package not found: $previous" >&2; exit 1; }
fi
# The channel the upgraded room is on. canary by default, which is the
# machine a new release goes to first; stable to rehearse the day the
# desktops are moved, from the release they are actually on.
channel="${VALIDATION_CHANNEL:-canary}"
case "$channel" in
stable|canary) ;;
*) echo "VALIDATION_CHANNEL must be stable or canary" >&2; exit 2 ;;
esac
echo "Validating $(basename "$candidate") on Debian Trixie"
docker_args=(
@@ -41,7 +51,7 @@ fi
# instead teaches everybody to re-run it until it passes.
timeout --signal=TERM --kill-after=15s 2700s \
docker "${docker_args[@]}" debian:trixie-slim \
bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER'
bash -s -- "${previous:+/packages/previous.deb}" "$channel" <<'CONTAINER'
set -Eeuo pipefail
validation_failure() {
@@ -87,6 +97,7 @@ run_logged() {
run_logged apt-update 300 apt-get update -qq
previous="${1:-}"
channel="${2:-canary}"
if [[ -n "$previous" ]]; then
run_logged previous-install 1200 apt-get install -y -qq "$previous"
@@ -94,7 +105,7 @@ if [[ -n "$previous" ]]; then
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
# Configuration carried forward from 0.1.36
UPDATE_URL=https://updates.riverofglorymn.org
CHANNEL=canary
CHANNEL=@CHANNEL@
DVD_ENABLED=false
UI_SCALE=1.25
MQTT_ENABLED=true
@@ -104,10 +115,28 @@ MQTT_USERNAME=classroom
MQTT_PASSWORD=preserve-me
CUSTOM_SETTING=preserve-me-too
CONFIG
sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf
previous_version="$(dpkg-query -W -f='${Version}' ekklesia-classroom)"
fi
run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
# THE VERSION, AS THE MACHINE REPORTS IT.
#
# Three places say which release a classroom is running: dpkg's database,
# which the updater compares against the manifest; the VERSION file the
# package carries; and the file name the release was published under.
# They have to be one number, or a room either never updates or updates
# for ever.
candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)"
[[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]]
[[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]]
echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}"
if [[ -n "${previous_version:-}" ]]; then
dpkg --compare-versions "$candidate_version" gt "$previous_version"
fi
command -v nmtui >/dev/null
command -v lpadmin >/dev/null
command -v lpinfo >/dev/null
@@ -376,6 +405,64 @@ timeout --signal=TERM --kill-after=5s 45s classroom-provision \
>/tmp/provision-second.log
grep -q 'Elmer is already correctly configured' /tmp/provision-second.log
# THE UPDATER THIS PACKAGE INSTALLED, AGAINST A MANIFEST.
#
# Served from this container, so nothing reaches the real update server.
# The installed version has to be "up to date" against a manifest naming
# itself — or the room reinstalls and reboots at every check — and has to
# see the next feature release as newer.
mkdir -p /tmp/updates/packages "/tmp/updates/$channel"
cp /packages/candidate.deb "/tmp/updates/packages/ekklesia-classroom_${candidate_version}_amd64.deb"
serve_manifest() {
python3 - "$1" "$channel" <<'MANIFEST'
import hashlib, json, sys
version, channel = sys.argv[1], sys.argv[2]
name = f"ekklesia-classroom_{version}_amd64.deb"
try:
digest = hashlib.sha256(open(f"/tmp/updates/packages/{name}", "rb").read()).hexdigest()
except FileNotFoundError:
digest = "0" * 64
json.dump({"version": version, "package": f"/packages/{name}",
"sha256": digest, "requires_reboot": True},
open(f"/tmp/updates/{channel}/manifest.json", "w"))
MANIFEST
}
python3 -m http.server --bind 127.0.0.1 --directory /tmp/updates 8765 \
>/tmp/updates.log 2>&1 &
updates_server=$!
sleep 1
cp /etc/ekklesia-classroom/device.conf /tmp/device.conf.before-updater
sed -i 's|^UPDATE_URL=.*|UPDATE_URL=http://127.0.0.1:8765|' \
/etc/ekklesia-classroom/device.conf
serve_manifest "$candidate_version"
ekklesia-classroom-update --check >/tmp/updater-same.log 2>&1
grep -q "Already up to date" /tmp/updater-same.log
# The next milestone after whatever this is, and a release every
# classroom has long since passed.
IFS=. read -r major minor _ <<< "$candidate_version"
next_version="${major}.$((minor + 1)).0"
serve_manifest "$next_version"
ekklesia-classroom-update --check >/tmp/updater-next.log 2>&1
grep -q "Update available: $candidate_version -> $next_version" /tmp/updater-next.log
serve_manifest 0.2.2
ekklesia-classroom-update --check >/tmp/updater-older.log 2>&1
grep -q "Already up to date" /tmp/updater-older.log
echo '{"version": "0.2.06", "package": "/packages/x.deb", "sha256": "x"}' \
> "/tmp/updates/$channel/manifest.json"
! ekklesia-classroom-update --check >/tmp/updater-bad.log 2>&1
grep -q "invalid version" /tmp/updater-bad.log
kill "$updates_server"
cp /tmp/device.conf.before-updater /etc/ekklesia-classroom/device.conf
if [[ -n "$previous" ]]; then
# The room's own values, untouched by the upgrade.
grep -q '^DVD_ENABLED=false$' /etc/ekklesia-classroom/device.conf
@@ -386,7 +473,17 @@ if [[ -n "$previous" ]]; then
# The channel a room was put on deliberately survives the upgrade.
# canary is where the next development release goes, and the Plasma
# classroom is the stable line now, so nothing here has to move.
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
grep -q "^CHANNEL=${channel}\$" /etc/ekklesia-classroom/device.conf
# An optional feature an existing room never had stays off. A
# Presentations launcher appearing on the desktops' panels by itself
# would be an upgrade changing a classroom, which upgrades do not do.
[[ "$(grep -c '^PRESENTATIONS_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
grep -q '^PRESENTATIONS_ENABLED=false$' /etc/ekklesia-classroom/device.conf
classroom-presentations 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false'
classroom-desktop-layout >/dev/null 2>&1 || true
grep -q 'var PRESENTATIONS = false;' \
/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
# And a room that was carried through the experiment on its own
# channel comes home, because that channel is the obsolete one now.
@@ -395,7 +492,7 @@ if [[ -n "$previous" ]]; then
grep -q '^CHANNEL=stable$' /etc/ekklesia-classroom/device.conf
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
sed -i 's/^CHANNEL=.*/CHANNEL=canary/' /etc/ekklesia-classroom/device.conf
sed -i "s/^CHANNEL=.*/CHANNEL=${channel}/" /etc/ekklesia-classroom/device.conf
# Every setting this release has that the room predates, appended
# exactly once. classroom-migrate hands device.conf to the shared
@@ -415,7 +512,7 @@ if [[ -n "$previous" ]]; then
done
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
grep -q "^CHANNEL=${channel}\$" /etc/ekklesia-classroom/device.conf
# And by hand, on a machine that is already converged: the command a
# technician runs must be safe to run twice.
+62 -57
View File
@@ -4,32 +4,50 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
current_version="$(cat VERSION)"
source "$ROOT/tools/release-lib.sh"
if [[ $# -eq 1 ]]; then
version="$1"
else
IFS='.' read -r major minor patch <<< "$current_version"
version="${major}.${minor}.$((patch + 1))"
# The version is always said out loud.
#
# With only three parts there was one obvious next number. With four
# there are two — 0.2.6 is followed by 0.2.6.1 if it is a fix and by
# 0.2.7 if it is not — and which one a release is, is a decision about
# what is in it, not arithmetic.
if [[ $# -ne 1 ]]; then
current="$(release_channel_version canary || true)"
echo "Usage: $0 VERSION" >&2
echo >&2
echo "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2
if [[ -n "$current" ]] && release_version_valid "$current"; then
IFS='.' read -r major minor patch maintenance <<< "$current"
echo " $0 ${major}.${minor}.${patch}.$(( ${maintenance:-0} + 1 )) a maintenance release" >&2
echo " $0 ${major}.${minor}.$(( patch + 1 )) the next feature release" >&2
fi
exit 2
fi
package_name="ekklesia-classroom_${version}_amd64.deb"
version="$1"
package_name="$(release_package_name "$version")"
package_path="$ROOT/dist/$package_name"
echo "Current version: $current_version"
echo "Current canary: $(release_channel_version canary || echo unknown)"
echo "New canary: $version"
echo
# Before anything is built: a version that has been published, or that
# would not be newer than everything that has been, stops here.
release_check_new_version "$version"
"$ROOT/tools/build-release.sh" "$version"
# Validate the exact artifact that will be published. If a previous canary is
# available locally, exercise the real upgrade path as well as a fresh install.
validation_args=("$package_path")
if [[ -f "$ROOT/repo/canary/manifest.json" ]]; then
if [[ -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
previous_package_name="$(python3 -c \
'import json; print(json.load(open("repo/canary/manifest.json"))["package"].rsplit("/", 1)[-1])')"
previous_package="$ROOT/repo/packages/$previous_package_name"
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \
"$RELEASE_LOCAL/canary/manifest.json")"
previous_package="$RELEASE_LOCAL/packages/$previous_package_name"
if [[ -f "$previous_package" && "$previous_package" != "$package_path" ]]; then
validation_args+=("$previous_package")
@@ -38,62 +56,49 @@ fi
"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}"
# And the stable rooms' own path, on the same file. Every canary is a
# stable release in waiting, and the desktops are not on the previous
# canary: they are on whatever stable names, which can be several
# releases back (0.2.2 under 0.2.6.1). A package that upgrades the
# laptop and breaks the desktops has to fail here, not on a Sunday.
if [[ -f "$RELEASE_LOCAL/stable/manifest.json" ]]; then
stable_package_name="$(python3 -c \
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \
"$RELEASE_LOCAL/stable/manifest.json")"
stable_package="$RELEASE_LOCAL/packages/$stable_package_name"
if [[ -f "$stable_package" && "$stable_package" != "${validation_args[1]:-}" ]]; then
echo
echo "Rehearsing the stable rooms' upgrade from $stable_package_name"
VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$package_path" "$stable_package"
fi
fi
# Checked again after the gate, which takes long enough for somebody else
# to have published in the meantime.
release_check_new_version "$version"
hash="$(sha256sum "$package_path" | awk '{print $1}')"
mkdir -p "$ROOT/repo/packages" "$ROOT/repo/canary"
cp "$package_path" "$ROOT/repo/packages/$package_name"
cat > "$ROOT/repo/canary/manifest.json" <<EOF
{
"version": "$version",
"package": "/packages/$package_name",
"sha256": "$hash",
"requires_reboot": true
}
EOF
mkdir -p "$RELEASE_LOCAL/packages"
cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name"
echo
echo "Publishing package..."
rsync -av \
-e ssh \
"$ROOT/repo/packages/$package_name" \
classroom-control:/srv/ekklesia/classroom-updates/packages/
release_upload_package "$RELEASE_LOCAL/packages/$package_name"
echo
echo "Publishing canary manifest..."
release_publish_manifest canary "$version" "$hash"
# Manifest deliberately goes LAST.
rsync -av \
-e ssh \
"$ROOT/repo/canary/manifest.json" \
classroom-control:/srv/ekklesia/classroom-updates/canary/manifest.json
echo "$RELEASE_URL/canary/latest.deb -> $package_name"
# THE PERMANENT URL.
#
# .../canary/latest.deb is a symlink beside the manifest pointing at the
# versioned package the manifest names, so there is one download address
# that never changes and still no second place recording which version is
# current. The manifest remains the only answer to that question.
#
# It is swapped AFTER the manifest, and by rename, for two reasons. A
# rename is atomic, so nothing ever downloads a half-written pointer; and
# going last means latest.deb can only ever lag the manifest by a moment,
# never lead it — it can never offer a package the channel has not
# published. The versioned file itself is never touched again once
# uploaded.
ssh classroom-control "
set -e
cd /srv/ekklesia/classroom-updates/canary
test -f ../packages/$package_name
ln -sfn ../packages/$package_name latest.deb.new
mv -Tf latest.deb.new latest.deb
"
echo "https://updates.riverofglorymn.org/canary/latest.deb -> $package_name"
echo
echo "Checking what a classroom will see..."
release_verify_channel canary "$version"
echo " stable is still on $(release_channel_version stable || echo unknown)"
echo
echo "Published Ekklesia Classroom $version to CANARY"
echo "https://updates.riverofglorymn.org/canary/manifest.json"
echo "$RELEASE_URL/canary/manifest.json"
+41 -55
View File
@@ -4,76 +4,62 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
source "$ROOT/tools/release-lib.sh"
# Stable never gets a package of its own. It is pointed at one canary
# already published, so the file every classroom downloads is byte for
# byte the one the canary machine ran.
if [[ $# -eq 1 ]]; then
version="$1"
package_name="ekklesia-classroom_${version}_amd64.deb"
package_path="$ROOT/repo/packages/$package_name"
if [[ ! -f "$package_path" ]]; then
echo "Package does not exist locally:"
echo "$package_path"
exit 1
fi
hash="$(sha256sum "$package_path" | awk '{print $1}')"
mkdir -p "$ROOT/repo/stable"
cat > "$ROOT/repo/stable/manifest.json" <<EOF
{
"version": "$version",
"package": "/packages/$package_name",
"sha256": "$hash",
"requires_reboot": true
}
EOF
else
if [[ ! -f "$ROOT/repo/canary/manifest.json" ]]; then
if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
echo "No local canary manifest exists."
exit 1
fi
mkdir -p "$ROOT/repo/stable"
cp "$ROOT/repo/canary/manifest.json" "$ROOT/repo/stable/manifest.json"
version="$(python3 -c \
'import json; print(json.load(open("repo/stable/manifest.json"))["version"])')"
'import json, sys; print(json.load(open(sys.argv[1]))["version"])' \
"$RELEASE_LOCAL/canary/manifest.json")"
fi
echo "Promoting $version to STABLE"
release_require_version "$version"
rsync -av \
-e ssh \
"$ROOT/repo/stable/manifest.json" \
classroom-control:/srv/ekklesia/classroom-updates/stable/manifest.json
package_name="$(release_package_name "$version")"
package_path="$RELEASE_LOCAL/packages/$package_name"
if [[ ! -f "$package_path" ]]; then
echo "Package does not exist locally:"
echo "$package_path"
exit 1
fi
package_name="ekklesia-classroom_${version}_amd64.deb"
if [[ "$(release_package_version "$package_path")" != "$version" ]]; then
echo "$package_name says it is $(release_package_version "$package_path")." >&2
exit 1
fi
# THE PERMANENT URL.
#
# .../stable/latest.deb is a symlink beside the manifest pointing at the
# versioned package the manifest names, so there is one download address
# that never changes and still no second place recording which version is
# current. The manifest remains the only answer to that question.
#
# It is swapped AFTER the manifest, and by rename, for two reasons. A
# rename is atomic, so nothing ever downloads a half-written pointer; and
# going last means latest.deb can only ever lag the manifest by a moment,
# never lead it — it can never offer a package the channel has not
# published. The versioned file itself is never touched again once
# uploaded.
ssh classroom-control "
set -e
cd /srv/ekklesia/classroom-updates/stable
test -f ../packages/$package_name
ln -sfn ../packages/$package_name latest.deb.new
mv -Tf latest.deb.new latest.deb
"
# A classroom only ever installs a version newer than its own, so a stable
# manifest that went backwards would reach nobody and would only make the
# channel say something untrue.
current="$(release_channel_version stable || true)"
echo "https://updates.riverofglorymn.org/stable/latest.deb -> $package_name"
if [[ -n "$current" ]] && ! release_version_gt "$version" "$current"; then
echo "Stable is on $current; $version is not newer." >&2
exit 1
fi
hash="$(sha256sum "$package_path" | awk '{print $1}')"
echo "Promoting $version to STABLE (from ${current:-nothing})"
# Refuses unless the server already holds exactly this package.
release_publish_manifest stable "$version" "$hash"
echo "$RELEASE_URL/stable/latest.deb -> $package_name"
echo
release_verify_channel stable "$version"
echo
echo "Ekklesia Classroom $version is now STABLE"
echo "https://updates.riverofglorymn.org/stable/manifest.json"
echo "$RELEASE_URL/stable/manifest.json"