Compare commits

12 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 4413114d2a docs: what a person checks before 0.2.6.6 goes to stable, and the file it is
Six boxes on the laptop: the reboot that waits for an idle, silent
computer, and the television switched on after the computer. The exact
file and its sha256, so that promotion names the bytes canary ran.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:39:37 -05:00
ekklesiaandClaude Opus 5.5 897a5f28ed fix: the gate installs a fresh machine as the stable room it is
A fresh device.conf says CHANNEL=stable, and the gate asked the fresh
install's updater about canary's manifest, which that machine never reads.
Found running the gate on 0.2.6.6: the updater check failed before any
upgrade was tried. The package was not at fault.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:16:10 -05:00
ekklesiaandClaude Opus 5.5 1a338b2d38 release: 0.2.6.6, for the canary machine only
A desktop Plasma makes after the layout has run — the television arriving
after a night-time reboot, as on Tiny Pebbles — now shows the classroom's
wallpaper, and every existing desktop is repaired before the shell starts.
Carries 0.2.6.5's reboot that waits until nobody is using the computer.

Built from this commit, gated on that file, and published to canary as it
is; the file and its sha256 are recorded in the Gitea release v0.2.6.6 and
in docs/testing.md. Stable stays on 0.2.6.4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:06:18 -05:00
ekklesiaandClaude Opus 5.5 aaa3f8d063 docs: a desktop the layout never saw, and the television check only a room can do
What Tiny Pebbles showed, what the lab measured (a new activity gives the
same bare desktop; a file path as the fallback is ignored; live D-Bus is
refused by the lockdown), the two parts of the fix, and the check with a
real television switched on after a reboot — the one thing the lab cannot
do, because KWin will not start with no screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:06:08 -05:00
ekklesiaandClaude Opus 5.5 a9700536bf fix: the file the gate passed is the file published, and stable takes only the approved one
tools/release-gate.sh is the one gate: a fresh install, and the upgrade
from what canary and stable serve, each on a room of that channel, plus
any older release rooms are still on (RELEASE_GATE_FROM). The releases it
upgrades from are fetched over HTTPS and checked against their manifests,
so it needs no credential and a CI runner can run it.

update_canary.sh VERSION PACKAGE gates and publishes that file without
rebuilding it, since a second build is a second, untested file; the hash
is taken before the gate and checked after it. update_stable.sh VERSION
SHA256 refuses anything but the file that was approved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 6c04f743ed test: every rehearsal installs with apt and proves which version landed
The dirty-upgrade lab installed the new package with dpkg -i. Since
0.2.6.2 the package conflicts with khelpcenter, which dpkg refuses and apt
resolves, so the 0.2.2 rehearsal left the machine on 0.2.2 and asked every
question of the old release. Old package, new package and the reinstall
now all go through apt, as ekklesia-classroom-update does, and each stops
the run unless dpkg reports exactly that version, fully installed. The
container gate asserts the same of its previous release and its reinstall.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 9fc86f9d4c test: the wallpaper repair, on Tiny Pebbles' own file, in the lab and on a real install
Unit: Tiny Pebbles' file (desktop 10, panel 1), a healthy River Kids file
left byte for byte, a late desktop beside a good one, idempotence, the
refusal under a running shell. Lab: Plasma makes a bare desktop itself
through a new activity, and the screen is read, not the file. Container
gate: the installed package's fallback is the package, and its repair
fixes the room's file and leaves the panel alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 7b4ac39cd2 fix: a desktop Plasma makes after the layout has run gets the classroom's wallpaper
Tiny Pebbles came through the 0.2.2 upgrade on Plasma's own purple
wallpaper: its desktop was containment 10, made after the layout had run
(most likely the television arriving after a night-time reboot), with no
wallpaper configured. The look and feel's fallback could not catch it,
because it named a file and Plasma only accepts a wallpaper package there.

The fallback is now the EkklesiaClassroom package, whose one image links to
the classroom's picture. classroom-desktop-wallpaper, run before every
plasmashell start, writes the picture into every desktop containment the
file already has: found by plugin, never by number; panels and everything
else byte for byte; a file that is already right not written at all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:44 -05:00
ekklesiaandClaude Opus 5.5 530f6f6563 release: 0.2.6.5, for the canary machine only
An update reboots the computer only once nobody has used it for half an
hour and nothing is playing, instead of the moment it is installed. The
update check stays daily at 03:00 local time, Persistent.

Published to canary: ekklesia-classroom_0.2.6.5_amd64.deb, sha256
48919410492d398d103c2fd2e4e5bd0a97761e7032f9438fbb08f1a29c045050, gated
as a fresh install and as an upgrade from 0.2.6.4 (which stable serves),
and rehearsed separately as the upgrade from 0.2.2. Upgraded live in the
lab 0.2.6.2 -> 0.2.6.4 -> 0.2.6.5: the timer stayed enabled, daily at
03:00, Persistent, with its last-check record kept. Stable still answers
0.2.6.4, promoted earlier today; nothing here wrote to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 23:10:26 -05:00
ekklesiaandClaude Opus 5.5 b2cde1f414 docs: when a classroom updates, and when it reboots
Daily at 03:00 local time spread over half an hour, caught up once after a
night switched off, on the device's own channel only; and since 0.2.6.5 a
reboot that waits for half an hour with nobody using the computer and
nothing playing. With the journal commands to see each, and the checks
only real hardware can make.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
ekklesiaandClaude Opus 5.5 7586326902 test: the lab measures the update schedule on its own systemd, powered off and on
Switched on at the time: the timer fires once and is next due tomorrow.
Switched off: the lab machine is really stopped across the time and
started after it; the missed check runs once, 50 s after starting, and
never loops. And the reboot-when-idle watcher against real kernel input,
a mouse made with /dev/uinput: no reboot while it moves, a reboot once it
is left alone. 11 checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
ekklesiaandClaude Opus 5.5 3c40e849ea fix: an update reboots the computer only once nobody is using it
The update check has been daily at 03:00, Persistent, since 0.1.5 — the
lab's installed stable machine logged it at 03:01 and 03:04 on successive
days — and it stays so. What changes is the reboot after an update. It
happened the moment the update was installed: harmless at 03:00, and a
room restarting itself mid-morning when a computer switched off at 03:00
caught up after being switched on.

Now the updater installs, with every check it had (the device's own
channel, a newer version, the manifest's SHA-256, name and version), and
starts classroom-reboot-when-idle, which reboots once no input device has
produced an event for 30 minutes and no sound card has played for as
long. At 03:00 that is about 03:30; switched off first, the next start is
on the new version. The service has no [Install] and nothing enables it.

The release gate checks after every install and upgrade that the timer is
enabled, daily at 03:00, Persistent, with no boot or repeat trigger and
no drop-in, and that the updater no longer reboots by itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
27 changed files with 2148 additions and 61 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.6.4
0.2.6.6
+11 -1
View File
@@ -24,8 +24,18 @@ cursorTheme=breeze_cursors
library=org.kde.breeze
theme=Breeze
# The picture a desktop gets when nothing has said which. That is every
# desktop Plasma makes on its own after the layout has run: a television
# switched on after the computer, a second screen, a new activity.
#
# A wallpaper PACKAGE NAME, not a file. Plasma's DefaultWallpaper looks it
# up as wallpapers/<name> and nothing else. This line was a path until
# 0.2.6.6, which Plasma cannot load as a package, so every such desktop
# fell back to Plasma's own purple one — measured, and it is what Tiny
# Pebbles showed after the 0.2.2 upgrade. The package is a directory whose
# one image is a link to the classroom's file; see build-release.sh.
[Wallpaper]
Image=/usr/share/backgrounds/ekklesia/classroom-calm.png
Image=EkklesiaClassroom
[KSplash]
Theme=None
@@ -37,6 +37,13 @@ var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
var PANEL_HEIGHT = 76;
// The idle desktop: the wallpaper, and nothing on it.
//
// Only the desktops that exist right now. A screen that appears later —
// the television switched on after a reboot at three in the morning, which
// is what Tiny Pebbles had — gets a desktop Plasma makes without this
// file. That one takes the look and feel's default wallpaper, which is the
// classroom's package, and classroom-desktop-wallpaper writes it in by
// name before the shell next starts.
var screens = desktops();
for (var i = 0; i < screens.length; i++) {
var desktop = screens[i];
@@ -0,0 +1,12 @@
{
"KPlugin": {
"Authors": [
{
"Name": "Ekklesia"
}
],
"Id": "EkklesiaClassroom",
"License": "GPLv2+",
"Name": "Ekklesia Classroom"
}
}
+1 -1
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 26 September 2026</p>
<p class="revision">River of Glory &middot; Revision 29 September 2026</p>
<nav id='contents'></nav>
<section id="00-welcome">
<h2>Your classroom computer</h2>
+1 -1
View File
@@ -46,7 +46,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 26 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 29 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
+44
View File
@@ -71,6 +71,50 @@ The boot provisioning command never upgrades or reconfigures anything already in
never touches a repository it did not add. Classroom software updates
are the OTA channel's job, not this.
## When a classroom updates
**Every day at 03:00**, local time, spread over half an hour so the rooms
do not all ask at once: `ekklesia-classroom-update.timer`,
`OnCalendar=*-*-* 03:00:00`, `RandomizedDelaySec=30m`. It has been so
since 0.1.5; the lab's installed stable machine logged its checks at 03:01
on 26 September and 03:04 on 27 September.
**A computer that was switched off at 03:00** checks once, soon after it
is next switched on (`Persistent=true`). Measured on the lab's systemd
with the machine really stopped across the time: the missed check ran 50 s
after it started, once, and not again. Nothing else starts the check —
no boot trigger, no repeat — so it cannot loop.
Each check follows only the device's own `CHANNEL` (a stable room stays
on stable), installs only a newer version, and only a package whose
SHA-256, name and version are what the manifest says.
**The reboot after an update waits for nobody to be using the computer**
(since 0.2.6.5). It used to happen the moment the update was installed:
harmless at 03:00, and a room restarting itself mid-morning after a night
switched off. Now `classroom-reboot-when-idle` reboots once no keyboard,
mouse, touchpad or touchscreen has been touched for 30 minutes *and* no
sound has played for as long — a video to the room is not an idle
computer. At 03:00 that is about 03:30. Switched off before then, nothing
is lost: the update is installed and the next start is on it. Until the
reboot the session carries on as it was; the only other thing an update
starts at once, provisioning, does nothing on a machine that is already
set up.
Input is read from the kernel's input devices, whatever the desktop is
doing; sound from the kernel's own playback state (`/proc/asound`). Both
measured in the lab — a mouse made with `/dev/uinput` kept it from
rebooting, and leaving it alone let it — except the sound card, which the
lab does not have: that is a check on real hardware.
The schedule is a file the package installs, not a configuration file,
so every upgrade brings the packaged schedule. The release gate checks it
after a fresh install and after each upgrade.
systemctl list-timers ekklesia-classroom-update.timer # next and last check
journalctl -u ekklesia-classroom-update # what each check did
journalctl -u ekklesia-classroom-reboot-when-idle # a reboot that is waiting
## Permanent download addresses
```
+72 -1
View File
@@ -3,6 +3,36 @@
Run against the exact artifact published to canary, on the real endpoint,
before promoting to stable with `./update_stable.sh`.
## Before 0.2.6.6 goes to stable
The container gate and the lab passed both of these. Neither can prove
them on real hardware, so a person does, on the prayer-room laptop, which
takes 0.2.6.6 from canary. If every box is ticked, 0.2.6.6 is promoted
**as the same file**, never rebuilt:
```
ekklesia-classroom_0.2.6.6_amd64.deb
sha256 2b972391974265ba220c58ae0a8f287664a42228f062278956b89017bd5a54f7
./update_stable.sh 0.2.6.6 2b972391974265ba220c58ae0a8f287664a42228f062278956b89017bd5a54f7
```
**Waiting to reboot (since 0.2.6.5)** — details under [OTA](#ota):
- [ ] Using the computer after an update has installed: it does not restart
- [ ] A video playing with sound, mouse left alone: it does not restart
- [ ] Left untouched and silent, it restarts about 30 minutes later, on
the new version
- [ ] Switched off before that restart: it starts normally next time, on
the new version, and does not restart again
**The television arriving late (0.2.6.6)** — details under
[the television switched on after a night-time reboot](#the-television-switched-on-after-a-night-time-reboot):
- [ ] Rebooted with the television off at the set (if the room allows it),
then switched on: the classroom's landscape, not Plasma's purple
- [ ] The panel is the room's own: same launchers, same order, clock bottom
right
## Boot
- [ ] Cold boot reaches the classroom shell with no terminal or window junk
@@ -11,6 +41,39 @@ before promoting to stable with `./update_stable.sh`.
- [ ] Dock appears, centred
- [ ] Clock appears, bottom right
### The television switched on after a night-time reboot
This is how Tiny Pebbles lost its wallpaper after the 0.2.2 upgrade, and
the lab cannot do it: KWin will not start a session with no screen, and
the lab cannot plug one in. The lab proves the same code path through a
new activity (`tools/lab/wallpaper-check`); only a real television
proves that switching it on takes that path. See
[ui-scale.md](ui-scale.md#a-desktop-the-layout-never-saw-0266). Do it once, on a
desktop whose television is switched off at the set, not the remote:
1. [ ] Television **off** at the set. `sudo reboot`, and wait five minutes.
2. [ ] Television **on**. The classroom's landscape is on the screen,
**not** Plasma's purple wallpaper. The panel is the room's own.
3. [ ] From the maintenance terminal (Meta+Ctrl+Alt+Shift+K),
`classroom-desktop-wallpaper --check`. Note what it says. If it
names a desktop that "had no wallpaper configured", the television
really did arrive after the shell, and the screen in step 2 is the
fix working
4. [ ] `systemctl --user restart plasma-plasmashell.service`: the panel
blinks and comes back, open windows stay, the wallpaper is unchanged
5. [ ] `classroom-desktop-wallpaper --check` now says "the classroom
wallpaper already"
6. [ ] Reboot with the television on: the same picture and the same panel
Tiny Pebbles can confirm the cause **before** it takes the release, because
the update reboots it and a login rebuilds the file. If its panel was built
with no screen, the panel is `[Containments][1]` next to the desktop's 10:
```
grep -A6 '^\[Containments\]\[[0-9]*\]$' \
~/.config/plasma-org.kde.plasma.desktop-appletsrc | grep -E '^\[|^plugin='
```
## Provisioning
- [ ] `journalctl -u ekklesia-classroom-provision` shows a clean run
@@ -466,7 +529,15 @@ With `LIVE_ENABLED=true`:
- [ ] `./update_canary.sh` publishes package first, manifest last
- [ ] The endpoint on the canary channel picks the update up
- [ ] SHA-256 verification passes
- [ ] Install succeeds and the endpoint reboots
- [ ] Install succeeds; `systemctl list-timers ekklesia-classroom-update.timer`
shows the next check at about 03:00 tomorrow
- [ ] (0.2.6.5) Switched on in the morning after a night off, with an update
waiting: it installs, `journalctl -u ekklesia-classroom-reboot-when-idle`
says it is waiting, and it does **not** restart while you use it
- [ ] Play a video with sound and leave the mouse alone for 40 minutes: no
restart while it plays; half an hour after it stops, it restarts
- [ ] Left untouched with nothing playing, it restarts about 30 minutes after
the update, and comes back on the new version
- [ ] Everything above still works after the update
- [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value
- [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains
+63
View File
@@ -47,6 +47,69 @@ package installs it at `/usr/share/backgrounds/ekklesia/classroom-calm.png`, the
path used by the packaged Plasma layout. The layout is rebuilt at login, so
upgraded classrooms receive the package wallpaper without per-room copying.
### A desktop the layout never saw (0.2.6.6)
Tiny Pebbles came through the 0.2.2 → 0.2.6.4 upgrade showing Plasma's own
purple wallpaper. Its desktop was containment 10 with no `Wallpaper` group;
River Kids' is containment 1 with one. The package and the picture were the
same on both.
The layout sets the wallpaper on the desktops that exist *while it runs*.
Plasma makes a desktop for every screen or activity that appears later, and
it makes those without the layout. 0.2.2's updater rebooted as soon as it
had installed, at 03:00–03:30. A television switched off at the set is
usually no screen at all, so the layout would have run with no desktop, and
the desktop Plasma made when the television came on was bare. A panel built
with no screen takes number 1 and its eight applets 2–9, which is exactly
how a desktop ends up as 10. **The television is inferred, not observed**.
See the hardware check in [testing.md](testing.md#the-television-switched-on-after-a-night-time-reboot).
Measured in the lab (Plasma 6.3.6, Debian 13):
| What was tried | What happened |
| --- | --- |
| Activity manager delayed 20 s at login | Plasma waited for it; desktop 1, wallpaper right. Not the cause |
| A new activity created mid-session | Plasma made desktop 11 with no `Wallpaper` group, the same shape as Tiny Pebbles' 10. The screen showed Plasma's *Next*; the panel was untouched |
| Look-and-feel default `Image=` set to a **file path** (0.2.2–0.2.6.5) | Ignored. `DefaultWallpaper` in libkworkspace6 looks the value up as a wallpaper **package** under `wallpapers/<name>`, the way Breeze says `Image=Next` |
| The same default set to a **package name**, whose one image links to the file | Desktop 11, still with no `Image`, showed the classroom's picture after a shell restart |
| Setting it live over D-Bus (`evaluateScript`, `plasma-apply-wallpaperimage`) | Refused: "Widgets are locked". The classroom's lockdown forbids the scripting console, and it stays forbidden |
| Disabling the only screen to reproduce a screenless start | KWin refuses ("Disabling all outputs … is not allowed"), and the nested lab cannot hot-plug a screen |
So there are two parts, and they do different jobs:
* **The default is now a package**, `/usr/share/wallpapers/EkklesiaClassroom`.
Its image is a link to the file above, and the look and feel names it.
Any desktop Plasma makes without the layout shows the classroom's picture
with nothing having to run. This is the fix for Tiny Pebbles.
* **`classroom-desktop-wallpaper`** writes `wallpaperplugin=org.kde.image` and
the classroom `Image` into every desktop containment already in the file,
so the configuration says what the screen shows. It finds desktops by
plugin (`org.kde.plasma.folder`, `org.kde.desktopcontainment`), never by
number. It changes nothing else, and it does not write a file that is
already right. It runs as plasmashell's `ExecStartPre`, the one moment
the shell does not own the file: every login, every reboot, every restart
after a crash. At a login it usually finds nothing, because
`classroom-desktop-reset` has just removed the file for the layout to
rebuild.
To apply it in a running session without logging out, from the maintenance
terminal:
```
classroom-desktop-wallpaper --check # read-only
systemctl --user restart plasma-plasmashell.service # the panel blinks, windows stay
```
Run without `--check` while the shell is up, it refuses and prints that
second line rather than edit the file underneath the shell.
Tests: `tests/test-classroom-desktop-wallpaper.sh` (in the build) covers
Tiny Pebbles' own file, a healthy River Kids file, differing numbers,
idempotence and the guard. `tools/lab/wallpaper-check` covers real Plasma,
reading the colour off the screen, and runs inside `tools/lab/dirty-upgrade`.
`tools/validate-trixie-release.sh` covers the installed package when
upgrading from a previous release.
## What the one number reaches
`classroom-ui-scale` prints it, clamped, and everything that cannot read
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Put the classroom's wallpaper on every desktop the shell has.
Tiny Pebbles came up after the 0.2.2 -> 0.2.6.4 upgrade on Plasma's own
purple wallpaper, with the right panel and the right package installed.
Its desktop was containment 10, with no Wallpaper group at all:
[Containments][10]
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
The layout script writes the wallpaper, but only onto the desktops that
exist while it runs. Plasma makes a desktop for every screen, and every
activity, that appears afterwards — and it makes them without the layout.
0.2.2's updater rebooted at three in the morning with the television
switched off at the set, so the layout ran with no screen and no desktop
at all, and the desktop Plasma made when the television came on was
bare. Reproduced in the lab through a new activity, which takes the same
path through the shell: containment 11, the same four lines, Plasma's
wallpaper.
Two things now close that. The look and feel's default wallpaper is a
real wallpaper package, so a desktop Plasma makes on its own comes up
with the classroom's picture — that is the fix, and it needs nothing to
run. And this, which writes the picture into every desktop the file
already has, so that the configuration says what the screen shows.
WHICH CONTAINMENT IS THE DESKTOP is answered by what it is, never by its
number: a top-level containment whose plugin is one of Plasma's desktop
containments. Tiny Pebbles' desktop was 10 and River Kids' is 1, and the
panel's number is whatever was free when it was built.
ONLY THE DESKTOPS ARE TOUCHED. Every panel, applet and line that is not a
desktop's wallpaper is written back byte for byte, and a file that is
already right is not written at all.
WHEN IT RUNS. Before plasmashell starts, as the shell's own ExecStartPre,
because the shell owns this file: editing it underneath a live shell was
measured to destroy the panel. That is every start — a login, a reboot,
and the restart after a crash — and at a login it usually finds nothing,
because classroom-desktop-reset has just removed the file for the layout
to rebuild. Run by hand, it refuses while the shell is running and says
how to apply it. Live D-Bus scripting is not an option: the classroom
locks the desktop and forbids the scripting console, and Plasma answers
"Widgets are locked" — measured.
classroom-desktop-wallpaper repair, if the shell is stopped
classroom-desktop-wallpaper --check say what is wrong, change nothing
classroom-desktop-wallpaper FILE work on FILE instead
"""
import os
import re
import sys
WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"
WALLPAPER_PLUGIN = "org.kde.image"
# Plasma's two desktop containments: Folder View, which is what the
# classroom's desktops are, and the plain Desktop. Panels, the system tray
# a 0.2.2-era profile may still carry and anything else are not desktops.
DESKTOP_PLUGINS = ("org.kde.plasma.folder", "org.kde.desktopcontainment")
# What the layout writes onto a desktop, so a repaired desktop and a
# freshly built one say the same thing.
WALLPAPER_KEYS = (
("FillMode", "2"),
("Image", f"file://{WALLPAPER}"),
)
APPLETSRC = "plasma-org.kde.plasma.desktop-appletsrc"
CONTAINMENT = re.compile(r"^\[Containments\]\[(\d+)\]$")
# key=value, with KConfig's optional [$i]-style flags or [locale] after
# the key. The flags are kept when a value is replaced.
ENTRY = re.compile(r"^([^=\[\s]+)((?:\[[^\]]*\])*)=(.*)$")
def say(message):
print(f"classroom-desktop-wallpaper: {message}", flush=True)
def groups(lines):
"""[(name, header, end)]: lines[header] is the group's header line, or
-1 for the lines before the first group, and end is exclusive."""
found = []
name, header = None, -1
for index, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
found.append((name, header, index))
name, header = stripped, index
found.append((name, header, len(lines)))
return found
def find_group(lines, name):
for group, header, end in groups(lines):
if group == name:
return header, end
return None
def entry(line):
match = ENTRY.match(line.rstrip("\n"))
return (match.group(1), match.group(2), match.group(3)) if match else None
def desktops(lines):
"""The ids of the desktop containments, whatever they are numbered."""
ids = []
for name, header, end in groups(lines):
match = CONTAINMENT.match(name or "")
if not match:
continue
for line in lines[header + 1:end]:
parsed = entry(line)
if parsed and parsed[0] == "plugin" and parsed[2] in DESKTOP_PLUGINS:
ids.append(match.group(1))
break
return ids
def set_entry(lines, group, key, value):
"""Make `key=value` true in an existing group. Returns what changed,
or None when it already was."""
header, end = find_group(lines, group)
last = header
for index in range(header + 1, end):
parsed = entry(lines[index])
if lines[index].strip():
last = index
if parsed and parsed[0] == key:
if parsed[2] == value:
return None
lines[index] = f"{key}{parsed[1]}={value}\n"
return f"{key} was [{parsed[2]}]"
lines.insert(last + 1, f"{key}={value}\n")
return f"{key} was missing"
def add_group(lines, containment, group):
"""A new group, placed after the last of its containment's own."""
prefix = f"[Containments][{containment}]"
after = None
for name, header, end in groups(lines):
if name == prefix or (name or "").startswith(prefix + "["):
after = end
# Back over the blank lines that separate it from the next group, so
# the new one sits with its containment and the spacing stays as
# KConfig writes it.
while after > 0 and not lines[after - 1].strip():
after -= 1
if after > 0 and not lines[after - 1].endswith("\n"):
lines[after - 1] += "\n"
block = ["\n", f"{group}\n"]
block += [f"{key}={value}\n" for key, value in WALLPAPER_KEYS]
lines[after:after] = block
def converge(text):
"""The file as it should be, and what was changed to make it so."""
lines = text.splitlines(keepends=True)
changes = []
for containment in desktops(lines):
top = f"[Containments][{containment}]"
change = set_entry(lines, top, "wallpaperplugin", WALLPAPER_PLUGIN)
if change:
changes.append(f"desktop {containment}: {change}")
group = f"{top}[Wallpaper][{WALLPAPER_PLUGIN}][General]"
if find_group(lines, group) is None:
add_group(lines, containment, group)
changes.append(f"desktop {containment}: had no wallpaper configured")
continue
for key, value in WALLPAPER_KEYS:
change = set_entry(lines, group, key, value)
if change:
changes.append(f"desktop {containment}: {change}")
return "".join(lines), changes
def default_path():
home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return os.path.join(home, APPLETSRC)
def shell_running(proc="/proc"):
"""plasmashell running as this user. Read from /proc rather than asked
of systemd, so a shell started any way at all counts, and rather than
of pgrep, which nothing guarantees is installed."""
uid = os.getuid()
for pid in os.listdir(proc):
if not pid.isdigit():
continue
try:
with open(os.path.join(proc, pid, "comm"), encoding="utf-8") as handle:
name = handle.read().strip()
if name == "plasmashell" and os.stat(os.path.join(proc, pid)).st_uid == uid:
return True
except OSError:
# Gone while it was being looked at.
continue
return False
def write(path, text):
"""Replace the file whole, so a full disk or a power cut leaves the old
one rather than half of a new one."""
temporary = f"{path}.classroom-new"
mode = os.stat(path).st_mode & 0o7777
with open(temporary, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, mode)
os.replace(temporary, path)
def main(argv):
check = "--check" in argv
paths = [arg for arg in argv if not arg.startswith("--")]
path = paths[0] if paths else default_path()
try:
with open(path, "r", encoding="utf-8") as handle:
before = handle.read()
except FileNotFoundError:
say("no desktop configuration yet; the layout writes the wallpaper")
return 0
except OSError as error:
say(f"cannot read {path}: {error}")
return 0
after, changes = converge(before)
found = desktops(before.splitlines(keepends=True))
if not found:
say("no desktop in the configuration yet; Plasma's default is the "
"classroom's wallpaper package")
return 0
if not changes:
say(f"desktop {', '.join(found)}: the classroom wallpaper already")
return 0
for change in changes:
say(change)
if check:
say("not changed (--check)")
return 1
# The shell owns the live file. Anything else is just a file.
if not paths and shell_running():
say("plasmashell is running, and editing its file underneath it")
say("destroys the panel. Apply it with:")
say(" systemctl --user restart plasma-plasmashell.service")
say("which repairs the file before the shell starts again.")
return 1
try:
write(path, after)
except OSError as error:
say(f"cannot write {path}: {error}")
return 1
say(f"the wallpaper is now configured on desktop {', '.join(found)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Reboot after an update, but only once nobody is using the computer.
The nightly update check (ekklesia-classroom-update.timer, 03:00, and
Persistent so that a computer switched off at 03:00 checks when it is next
switched on) used to reboot the moment an update was installed. At 03:00
that is harmless. Switched on in the morning after a night off, it meant a
classroom computer restarting itself half an hour into the day, in front
of a teacher.
So the updater installs, then starts this, and this reboots only when:
nothing has touched any keyboard, mouse, touchpad or touchscreen for
IDLE_SECONDS — seen here as kernel input events, which is what every
one of them produces, whatever the desktop is doing; and
no sound has played for as long — a video to the room has no input for
an hour and is not an idle computer, and the end of it is not the
moment to restart either. Seen as the kernel's own playback state
(/proc/asound/.../status says RUNNING while a stream plays).
At 03:00 that is about 03:30. In the middle of a morning it is half an
hour after the teacher last touched it. If the computer is switched off
first, nothing is lost: the update is installed, and the next start is on
it. Until then the running session carries on as it was.
The time starts when this starts: input from before cannot be seen, so a
computer is only called idle after this has watched it be idle.
classroom-reboot-when-idle [--idle-seconds N] [--reboot-command CMD...]
The options are for the lab; the service runs it with none.
"""
import argparse
import glob
import os
import select
import subprocess
import sys
import time
IDLE_SECONDS = 30 * 60
# How often it looks: input is noticed at once, the rest once a minute is
# plenty for a half-hour decision.
CHECK_SECONDS = 60
INPUT_GLOB = "/dev/input/event*"
ASOUND_ROOT = "/proc/asound"
def log(message):
print(f"[classroom-reboot-when-idle] {message}", flush=True)
def audio_playing(root=ASOUND_ROOT):
"""Is any sound card playing right now?"""
for status in glob.glob(os.path.join(root, "card*", "pcm*p", "sub*", "status")):
try:
with open(status, encoding="utf-8", errors="replace") as handle:
if handle.readline().strip() == "state: RUNNING":
return True
except OSError:
continue
return False
def should_reboot(now, last_input, idle_seconds, playing):
"""The decision: long enough without input, and nothing playing."""
return not playing and now - last_input >= idle_seconds
class Inputs:
"""Every input device the kernel has, reopened as they come and go."""
def __init__(self, pattern=INPUT_GLOB):
self.pattern = pattern
self.open = {}
def rescan(self):
present = set(glob.glob(self.pattern))
for fd, path in list(self.open.items()):
if path not in present:
os.close(fd)
del self.open[fd]
known = set(self.open.values())
for path in sorted(present - known):
try:
self.open[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = path
except OSError:
continue
def wait(self, seconds):
"""True if anything happened on any device within SECONDS."""
if not self.open:
time.sleep(seconds)
return False
ready, _, _ = select.select(list(self.open), [], [], seconds)
seen = False
for fd in ready:
try:
while os.read(fd, 4096):
seen = True
except BlockingIOError:
pass
except OSError:
# A device that went away mid-read; the next rescan drops it.
pass
return seen
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--idle-seconds", type=float, default=IDLE_SECONDS)
parser.add_argument("--check-seconds", type=float, default=CHECK_SECONDS)
parser.add_argument("--input-glob", default=INPUT_GLOB)
parser.add_argument("--asound-root", default=ASOUND_ROOT)
parser.add_argument("--reboot-command", nargs="+", default=["systemctl", "reboot"])
args = parser.parse_args(argv)
inputs = Inputs(args.input_glob)
inputs.rescan()
last_input = time.monotonic()
log(f"an update is installed; rebooting once nobody has used this computer for "
f"{round(args.idle_seconds / 60)} minutes and nothing is playing "
f"(watching {len(inputs.open)} input devices)")
while True:
deadline = time.monotonic() + args.check_seconds
while time.monotonic() < deadline:
if inputs.wait(max(0.0, min(5.0, deadline - time.monotonic()))):
last_input = time.monotonic()
inputs.rescan()
playing = audio_playing(args.asound_root)
if playing:
# Sound counts as someone there: a 40-minute video ending is
# not the moment to restart, so the half hour starts after it.
last_input = time.monotonic()
if should_reboot(time.monotonic(), last_input, args.idle_seconds, playing):
log("nobody has used it, and nothing is playing: rebooting")
return subprocess.run(args.reboot_command).returncode
if __name__ == "__main__":
sys.exit(main())
+7 -2
View File
@@ -14,6 +14,7 @@ from urllib.parse import urljoin
CONFIG = "/etc/ekklesia-classroom/device.conf"
LOCKFILE = "/run/ekklesia-classroom-update.lock"
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
CACHE_DIR = "/var/cache/ekklesia-classroom"
PACKAGE = "ekklesia-classroom"
@@ -291,8 +292,12 @@ def main():
if args.no_reboot:
log("Update requires reboot, but --no-reboot was specified.")
else:
log("Update requires reboot. Rebooting now.")
subprocess.run(["systemctl", "reboot"])
# Not now. At 03:00 nobody is there; after a missed 03:00,
# checked when the computer is next switched on, somebody
# usually is. classroom-reboot-when-idle waits until nobody
# has touched it for half an hour and nothing is playing.
log("Update requires reboot. Rebooting once nobody is using this computer.")
subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE])
return 0
@@ -0,0 +1,10 @@
[Unit]
Description=Reboot after an Ekklesia Classroom update, once nobody is using the computer
Documentation=file:///usr/local/bin/classroom-reboot-when-idle
# Started by ekklesia-classroom-update after it installs an update that
# needs a reboot; never at boot. A computer switched off before it
# reboots simply starts on the new version.
[Service]
Type=simple
ExecStart=/usr/local/bin/classroom-reboot-when-idle
@@ -16,3 +16,9 @@ StartLimitIntervalSec=0
[Service]
Restart=always
RestartSec=2
# Every desktop the configuration already has gets the classroom's
# wallpaper written into it, in the one moment the shell is not running to
# own the file — at a login, and at every restart after a crash. See
# classroom-desktop-wallpaper. The "-" is because a panel matters more than
# a wallpaper: nothing that script can do is allowed to stop the shell.
ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper
+408
View File
@@ -0,0 +1,408 @@
#!/bin/bash
# Every desktop shows the classroom's wallpaper, whatever it is numbered.
#
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop as
# containment 10 and no wallpaper configured on it, and showed Plasma's own
# purple one. These run the real repair against that file, against a
# machine that was already right, and against the shapes in between, and
# then check that every place naming the wallpaper names the same one.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python3 - "$ROOT" <<'PY'
import importlib.util
import json
import os
import re
import sys
import tempfile
from importlib.machinery import SourceFileLoader
from pathlib import Path
root = Path(sys.argv[1])
loader = SourceFileLoader("wallpaper", str(root / "scripts/classroom-desktop-wallpaper"))
spec = importlib.util.spec_from_loader("wallpaper", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
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
IMAGE = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
def group_texts(text):
"""{group name: its lines}, for saying what did and did not change."""
lines = text.splitlines(keepends=True)
return {
name: "".join(lines[header:end]).rstrip("\n")
for name, header, end in module.groups(lines)
}
def containment_ids(text):
return sorted(re.findall(r"^\[Containments\]\[(\d+)\]$", text, re.M))
# A panel built with no screen present takes id 1 and its applets 2 to 9,
# which is how Tiny Pebbles' desktop came to be 10. Its containment is as
# it was read off the machine.
PANEL_1 = """[ActionPlugins][0]
MiddleButton;NoModifier=org.kde.paste
RightButton;NoModifier=org.kde.contextmenu
[ActionPlugins][1]
RightButton;NoModifier=org.kde.contextmenu
[Containments][1]
activityId=
formfactor=2
immutability=1
lastScreen=0
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][1][Applets][2]
immutability=1
plugin=org.kde.plasma.quicklaunch
[Containments][1][Applets][2][Configuration][General]
launcherUrls=file:///usr/share/applications/ekklesia-parent-call.desktop
[Containments][1][Applets][3]
immutability=1
plugin=org.kde.plasma.panelspacer
[Containments][1][Applets][4]
immutability=1
plugin=org.kde.plasma.icontasks
[Containments][1][Applets][4][Configuration][General]
launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop
sortingStrategy=1
[Containments][1][Applets][5]
immutability=1
plugin=org.kde.plasma.panelspacer
[Containments][1][Applets][6]
immutability=1
plugin=org.kde.plasma.networkmanagement
[Containments][1][Applets][7]
immutability=1
plugin=org.kde.plasma.volume
[Containments][1][Applets][8]
immutability=1
plugin=org.kde.plasma.notifications
[Containments][1][Applets][9]
immutability=1
plugin=org.kde.plasma.digitalclock
[Containments][1][General]
AppletOrder=2;3;4;5;6;7;8;9
"""
TINY_PEBBLES = PANEL_1 + """
[Containments][10]
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
[ScreenMapping]
itemsOnDisabledScreens=
"""
# The healthy machine, as the layout builds it with a screen present and
# as the lab's own session wrote it: desktop 1, panel 2, applets 3 to 10.
# The clock is applet 10 — the number that is Tiny Pebbles' desktop.
RIVER_KIDS = """[ActionPlugins][0]
MiddleButton;NoModifier=org.kde.paste
RightButton;NoModifier=org.kde.contextmenu
[Containments][1]
ItemGeometries-1920x1080=
ItemGeometriesHorizontal=
activityId=a9db02f6-926f-4650-b05f-de3a9f0b73fe
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][1][Wallpaper][org.kde.image][General]
FillMode=2
Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png
SlidePaths=
[Containments][2]
activityId=
formfactor=2
immutability=1
lastScreen=0
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][2][Applets][10]
immutability=1
plugin=org.kde.plasma.digitalclock
[Containments][2][Applets][10][Configuration][Appearance]
showDate=true
[Containments][2][Applets][3]
immutability=1
plugin=org.kde.plasma.quicklaunch
[Containments][2][Applets][5]
immutability=1
plugin=org.kde.plasma.icontasks
[Containments][2][General]
AppletOrder=3;4;5;6;7;8;9;10
[ScreenMapping]
itemsOnDisabledScreens=
"""
# What the lab produced when Plasma made a desktop mid-session: the good
# one kept its picture, and the new one, 11, had none.
LATE_DESKTOP = RIVER_KIDS.replace("[ScreenMapping]", """[Containments][11]
activityId=f992593c-2347-4e8d-be82-f80d02e71158
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][11][General]
positions={"1920x1080":[]}
[ScreenMapping]""")
print("1. Tiny Pebbles: a desktop numbered 10, with no wallpaper at all")
check("the desktop is found by what it is, not its number",
module.desktops(TINY_PEBBLES.splitlines(True)) == ["10"],
str(module.desktops(TINY_PEBBLES.splitlines(True))))
after, changes = module.converge(TINY_PEBBLES)
groups_before, groups_after = group_texts(TINY_PEBBLES), group_texts(after)
wallpaper = groups_after.get("[Containments][10][Wallpaper][org.kde.image][General]", "")
check("it gets the classroom's image", IMAGE in wallpaper, wallpaper)
check("scaled and cropped, as the layout writes it", "FillMode=2" in wallpaper)
check("and it says what it did", changes == ["desktop 10: had no wallpaper configured"],
str(changes))
check("the wallpaper plugin is still the image one",
"wallpaperplugin=org.kde.image" in groups_after["[Containments][10]"])
check("one group added and nothing else changed",
set(groups_after) - set(groups_before)
== {"[Containments][10][Wallpaper][org.kde.image][General]"}
and all(groups_after[name] == text for name, text in groups_before.items()))
check("every containment keeps its number", containment_ids(after) == ["1", "10"])
check("the panel is still containment 1, applets 2 to 9, in order",
groups_after["[Containments][1][General]"].endswith("AppletOrder=2;3;4;5;6;7;8;9")
and groups_after["[Containments][1]"] == groups_before["[Containments][1]"])
check("the new group sits with its own containment",
after.index("[Containments][10][General]")
< after.index("[Containments][10][Wallpaper]")
< after.index("[ScreenMapping]"))
again, changes_again = module.converge(after)
check("a second run changes nothing", again == after and changes_again == [], str(changes_again))
print("2. River Kids: already right")
after, changes = module.converge(RIVER_KIDS)
check("the file comes back byte for byte", after == RIVER_KIDS)
check("and nothing is reported as changed", changes == [], str(changes))
check("applet 10 on the panel is not mistaken for a desktop",
module.desktops(RIVER_KIDS.splitlines(True)) == ["1"])
print("3. two desktops, one bare, as the lab made them")
after, changes = module.converge(LATE_DESKTOP)
groups_before, groups_after = group_texts(LATE_DESKTOP), group_texts(after)
check("only the bare one is written",
changes == ["desktop 11: had no wallpaper configured"], str(changes))
check("the good one is untouched",
groups_after["[Containments][1][Wallpaper][org.kde.image][General]"]
== groups_before["[Containments][1][Wallpaper][org.kde.image][General]"])
check("and the panel, numbered 2, is untouched",
all(groups_after[name] == text for name, text in groups_before.items()
if name and name.startswith("[Containments][2]")))
check("the new one has the image",
IMAGE in groups_after.get("[Containments][11][Wallpaper][org.kde.image][General]", ""))
print("4. a desktop some other profile left with another picture")
OTHER = """[Containments][4]
formfactor=0
location=0
plugin=org.kde.desktopcontainment
wallpaperplugin=org.kde.color
[Containments][4][Wallpaper][org.kde.image][General]
Image[$e]=file:///usr/share/wallpapers/Next/contents/images/1920x1080.png
SlidePaths=/usr/share/wallpapers/
[Containments][7]
formfactor=2
location=4
plugin=org.kde.plasma.private.systemtray
[Containments][9]
formfactor=2
location=4
plugin=org.kde.panel
"""
after, changes = module.converge(OTHER)
groups_after = group_texts(after)
check("the plain Desktop containment counts as a desktop",
module.desktops(OTHER.splitlines(True)) == ["4"])
check("its plugin becomes the image one",
"wallpaperplugin=org.kde.image" in groups_after["[Containments][4]"])
wallpaper = groups_after["[Containments][4][Wallpaper][org.kde.image][General]"]
check("its image becomes the classroom's, keeping the key's flags",
"Image[$e]=file:///usr/share/backgrounds/ekklesia/classroom-calm.png" in wallpaper,
wallpaper)
check("a missing key is added to the existing group", "FillMode=2" in wallpaper)
check("a key the classroom does not set is left alone",
"SlidePaths=/usr/share/wallpapers/" in wallpaper)
check("a system tray is not a desktop",
groups_after["[Containments][7]"] == group_texts(OTHER)["[Containments][7]"])
check("nor is a panel",
groups_after["[Containments][9]"] == group_texts(OTHER)["[Containments][9]"])
check("and it settles", module.converge(after) == (after, []))
print("5. the command, on files")
work = Path(tempfile.mkdtemp())
def run(*args):
return module.main([str(arg) for arg in args])
target = work / "tiny-pebbles"
target.write_text(TINY_PEBBLES)
os.chmod(target, 0o600)
check("--check says it is wrong", run("--check", target) == 1)
check("and changes nothing", target.read_text() == TINY_PEBBLES)
check("the repair succeeds", run(target) == 0)
check("the file is repaired", IMAGE in target.read_text())
check("the file keeps its permissions", (target.stat().st_mode & 0o777) == 0o600)
check("no temporary file is left behind", sorted(p.name for p in work.iterdir()) == ["tiny-pebbles"])
stamp = (target.stat().st_ino, target.stat().st_mtime_ns)
check("a second run succeeds", run(target) == 0)
check("and does not write the file at all",
(target.stat().st_ino, target.stat().st_mtime_ns) == stamp)
check("--check then says it is right", run("--check", target) == 0)
healthy = work / "river-kids"
healthy.write_text(RIVER_KIDS)
stamp = (healthy.stat().st_ino, healthy.stat().st_mtime_ns)
check("a machine that is right is left alone", run(healthy) == 0
and (healthy.stat().st_ino, healthy.stat().st_mtime_ns) == stamp)
check("no file at all is not an error", run(work / "absent") == 0)
check("and none is created", not (work / "absent").exists())
empty = work / "no-desktop"
empty.write_text(PANEL_1)
check("a file with only a panel is not an error", run(empty) == 0)
check("and is not changed", empty.read_text() == PANEL_1)
print("6. never underneath a running shell")
config_home = work / "config"
config_home.mkdir()
live = config_home / module.APPLETSRC
live.write_text(TINY_PEBBLES)
os.environ["XDG_CONFIG_HOME"] = str(config_home)
check("the default file is the one in XDG_CONFIG_HOME", module.default_path() == str(live))
proc = work / "proc"
for pid, name in (("1", "systemd"), ("812", "kwin_wayland"), ("self", "python3")):
(proc / pid).mkdir(parents=True)
(proc / pid / "comm").write_text(name + "\n")
check("no plasmashell in the process table is no shell",
module.shell_running(str(proc)) is False)
(proc / "1044").mkdir()
(proc / "1044" / "comm").write_text("plasmashell\n")
check("this user's plasmashell is found", module.shell_running(str(proc)) is True)
check("and the real process table can be read",
module.shell_running() in (True, False))
module.shell_running = lambda: True
check("with plasmashell running it refuses", run() == 1)
check("and the shell's file is untouched", live.read_text() == TINY_PEBBLES)
check("--check still answers while it runs", run("--check") == 1)
module.shell_running = lambda: False
check("with the shell stopped it repairs", run() == 0 and IMAGE in live.read_text())
print("7. one wallpaper, named the same everywhere")
layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text()
defaults = (root / "config/plasma/lookandfeel/contents/defaults").read_text()
build = (root / "tools/build-release.sh").read_text()
dropin = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text()
package = root / "config/wallpapers/EkklesiaClassroom"
metadata = json.loads((package / "metadata.json").read_text())
check("the layout and the repair name the same file",
f'var WALLPAPER = "{module.WALLPAPER}";' in layout)
check("and the same fill mode", 'desktop.writeConfig("FillMode", 2);' in layout
and ("FillMode", "2") in module.WALLPAPER_KEYS)
check("the packaged file is where they say", f'"$STAGE{module.WALLPAPER}"' in build)
default = re.search(r"^\[Wallpaper\]\nImage=(.*)$", defaults, re.M)
check("the look and feel's default is a package name, not a path",
default is not None and "/" not in default.group(1),
default.group(1) if default else "no [Wallpaper] Image")
check("the package it names is the classroom's",
default is not None and default.group(1) == metadata["KPlugin"]["Id"] == package.name)
check("the build installs that package's metadata",
f'"$ROOT/config/wallpapers/{package.name}/metadata.json"' in build
and f'WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/{package.name}"' in build)
check("its image is a link to the one file, not a copy",
f"ln -s {module.WALLPAPER}" in build)
check("the repair is packaged",
'"$STAGE/usr/local/bin/classroom-desktop-wallpaper"' in build)
check("it runs before every shell start",
"ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" in dropin)
check("and a failure in it cannot stop the shell",
not re.search(r"^ExecStartPre=/", dropin, re.M))
check("the layout still writes the wallpaper for a fresh desktop",
'desktop.writeConfig("Image", "file://" + WALLPAPER);' in layout)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# When a classroom checks for updates, and when it reboots after one.
#
# The check: daily at 03:00, caught up once after a computer that was off
# at 03:00 is switched on. The reboot: only once nobody has used the
# computer for half an hour and nothing is playing. The timer itself is
# measured in the lab (tools/lab/update-schedule-check); here are its
# settings, and classroom-reboot-when-idle run for real with a pipe
# standing in for a keyboard and files standing in for sound cards.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
WORK="$WORK" python3 - "$ROOT" <<'PY'
import configparser
import importlib.machinery
import importlib.util
import os
import sys
import threading
import time
from pathlib import Path
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
loader = importlib.machinery.SourceFileLoader("idle", str(root / "scripts/classroom-reboot-when-idle"))
spec = importlib.util.spec_from_loader("idle", loader)
idle = importlib.util.module_from_spec(spec)
loader.exec_module(idle)
def unit(path):
parser = configparser.ConfigParser(strict=False, interpolation=None)
parser.optionxform = str
parser.read(path)
return parser
print("1. the check: daily at 03:00, caught up once after being off")
timer = unit(root / "systemd/updater/ekklesia-classroom-update.timer")
check("every day at 03:00", timer["Timer"].get("OnCalendar") == "*-*-* 03:00:00", dict(timer["Timer"]))
check("a computer off at 03:00 checks when it is next on (Persistent)", timer["Timer"].get("Persistent") == "true")
check("spread over half an hour, so rooms do not all ask at once", timer["Timer"].get("RandomizedDelaySec") == "30m")
check("and nothing else starts it: no boot or repeating trigger to loop on",
not any(k in timer["Timer"] for k in ("OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec", "OnActiveSec")))
check("enabled by the package on every install and upgrade",
"systemctl enable ekklesia-classroom-update.timer" in (root / "packaging/debian/postinst").read_text())
check("and installed as a package file, so an upgrade always brings the packaged schedule",
"systemd/updater/ekklesia-classroom-update.timer" in (root / "tools/build-release.sh").read_text()
and not (root / "packaging/debian/conffiles").exists())
print()
print("2. the updater: the same checks, and no reboot of its own")
updater = (root / "scripts/ekklesia-classroom-update").read_text()
code = "\n".join(l for l in updater.splitlines() if not l.lstrip().startswith("#"))
check("only the device's own channel", 'f"{channel}/manifest.json"' in code and 'channel = config["CHANNEL"]' in code)
check("only a newer version", "version_is_newer(candidate, current)" in code)
check("only a manifest that makes sense", "manifest_problem(manifest)" in code)
check("only a file with the manifest's SHA-256", "actual_hash = sha256(temporary_path)" in code)
check("only the package and version the manifest names", "if name != PACKAGE or version != candidate:" in code)
check("never reboots by itself any more", '["systemctl", "reboot"]' not in code)
check("but hands the reboot to classroom-reboot-when-idle, without waiting for it",
'"systemctl", "start", "--no-block", REBOOT_WHEN_IDLE' in code
and 'REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"' in code)
service = unit(root / "systemd/updater/ekklesia-classroom-reboot-when-idle.service")
check("which is never started at boot (no [Install])", "Install" not in service.sections())
check("and is not enabled by the package",
"enable ekklesia-classroom-reboot-when-idle" not in (root / "packaging/debian/postinst").read_text())
build = (root / "tools/build-release.sh").read_text()
check("both are packaged", "scripts/classroom-reboot-when-idle" in build
and "systemd/updater/ekklesia-classroom-reboot-when-idle.service" in build)
print()
print("3. is anything playing?")
def cards(states):
base = work / f"asound-{len(list(work.glob('asound-*')))}"
for n, state in enumerate(states):
status = base / f"card{n}" / "pcm0p" / "sub0" / "status"
status.parent.mkdir(parents=True)
status.write_text(state + "\n")
return base
check("nothing: no cards at all", not idle.audio_playing(work / "none"))
check("nothing: a card that is closed", not idle.audio_playing(cards(["closed"])))
check("nothing: a card that is open but not running", not idle.audio_playing(cards(["state: SETUP"])))
check("playing: a card that is running", idle.audio_playing(cards(["state: RUNNING\nowner_pid : 1"])))
check("playing: one of two cards running (the television's)", idle.audio_playing(cards(["closed", "state: RUNNING"])))
capture = work / "asound-capture" / "card0" / "pcm0c" / "sub0" / "status"
capture.parent.mkdir(parents=True)
capture.write_text("state: RUNNING\n")
check("a microphone recording is not playing", not idle.audio_playing(work / "asound-capture"))
print()
print("4. the decision")
check("half an hour untouched, nothing playing: reboot", idle.should_reboot(2000, 100, 1800, False))
check("29 minutes: not yet", not idle.should_reboot(1840, 100, 1800, False))
check("an hour untouched but a video playing: not while it plays", not idle.should_reboot(4000, 100, 1800, True))
check("half an hour by default", idle.IDLE_SECONDS == 30 * 60)
print()
print("5. run for real: a keyboard that is used, then left")
keyboard = work / "input" / "event0"
keyboard.parent.mkdir()
os.mkfifo(keyboard)
os.mkfifo(work / "input" / "event1") # a second device, never used
marker = work / "rebooted"
asound = cards(["closed"])
holder = os.open(keyboard, os.O_RDWR | os.O_NONBLOCK) # keeps the pipe open, as a device is
holder1 = os.open(work / "input" / "event1", os.O_RDWR | os.O_NONBLOCK)
result = {}
def run():
result["code"] = idle.main(["--idle-seconds", "2", "--check-seconds", "0.2",
"--input-glob", str(work / "input" / "event*"),
"--asound-root", str(asound),
"--reboot-command", "touch", str(marker)])
thread = threading.Thread(target=run, daemon=True)
started = time.monotonic()
thread.start()
for _ in range(10): # typing every 0.4 s for 4 s
os.write(holder, b"k" * 24)
time.sleep(0.4)
check("while the keyboard is used, no reboot (4 s against a 2 s limit)", not marker.exists())
thread.join(8)
waited = time.monotonic() - started
check(f"left alone, it reboots once the limit has passed ({waited:.1f} s)",
marker.exists() and 5.5 <= waited <= 8, result)
marker.unlink(missing_ok=True)
playing = cards(["state: RUNNING"])
result.clear()
def run_playing():
result["code"] = idle.main(["--idle-seconds", "1", "--check-seconds", "0.2",
"--input-glob", str(work / "input" / "event*"),
"--asound-root", str(playing),
"--reboot-command", "touch", str(marker)])
thread = threading.Thread(target=run_playing, daemon=True)
thread.start()
time.sleep(3)
check("untouched but playing for 3 s against a 1 s limit: no reboot", not marker.exists())
((playing / "card0" / "pcm0p" / "sub0" / "status")).write_text("closed\n")
stopped = time.monotonic()
thread.join(6)
after = time.monotonic() - stopped
# Sound is seen at each check, so the limit runs from the last check that
# heard it: at most one check (0.2 s here, a minute in a classroom) early.
check(f"the sound stops: it waits the limit again, less at most one check, before rebooting ({after:.1f} s)",
marker.exists() and 0.75 <= after <= 3, result)
os.close(holder)
os.close(holder1)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+37 -6
View File
@@ -244,20 +244,51 @@ 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"')
validation = canary.index('"$ROOT/tools/release-gate.sh"')
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_upload)
check("the release gate runs before the repository copy", validation < package_copy)
check("the release gate 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)
# A package handed in is the one published: a second build is a second,
# untested file.
build_at = canary.index('"$ROOT/tools/build-release.sh"')
check("a package handed in is not rebuilt",
canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at) < build_at
and "else" in canary[canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at):build_at])
check("and must say it is the version being published",
'release_package_version "$package_path")" != "$version"' in canary)
check("the file is hashed before the gate and checked after it",
canary.index('hash="$(sha256sum "$package_path"') < validation
< canary.index("changed while it was being gated") < first_upload)
check("the repository copy is proven to be the gated file",
package_copy < canary.index("is not the file that passed the gate") < first_upload)
gate = (root / "tools/release-gate.sh").read_text()
gate_code = "\n".join(line for line in gate.splitlines()
if not line.lstrip().startswith("#"))
check("the gate runs a fresh install, as the stable room a new machine is",
'env VALIDATION_CHANNEL=stable \\\n "$ROOT/tools/validate-trixie-release.sh" "$candidate"\n'
in gate_code)
check("which is what a fresh device.conf says",
"CHANNEL=stable" in (root / "lib/classroom_config.py").read_text())
check("and the upgrade from what canary and stable serve, as rooms of each",
"for channel in canary stable" in gate_code
and 'VALIDATION_CHANNEL="$channel"' in gate_code)
check("the stable rooms' older releases can be rehearsed too",
"VALIDATION_CHANNEL=stable" in gate_code and "RELEASE_GATE_FROM" in canary)
check("what it upgrades from is checked against its manifest's checksum",
'if [[ "$got" != "$from_hash" ]]' in gate_code)
check("the gate needs no credential and writes nothing to the server",
"release_remote" not in gate_code and "ssh" not in gate_code
and "rsync" not in gate_code)
check("the gate stops at the first failure",
"set -euo pipefail" in gate and "inherit_errexit" in gate)
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
+29 -2
View File
@@ -274,8 +274,21 @@ 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 "refuses a checksum that is not one" \
refuses "$ROOT/update_stable.sh" 0.2.6.1 not-a-checksum
check "refuses a file that is not the one approved" \
refuses "$ROOT/update_stable.sh" 0.2.6.1 "$(printf '0%.0s' {1..64})"
check "stable is still 0.2.2 after all of that" \
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
approved="$(sha256sum "$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')"
check "promotes the approved file, named by its checksum" \
"$ROOT/update_stable.sh" 0.2.6.1 "$approved"
check "and stable now serves exactly that file" \
bash -c "grep -q '\"sha256\": \"$approved\"' '$SERVER/stable/manifest.json' \
&& cmp -s '$SERVER/stable/latest.deb' '$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb'"
# Back to 0.2.2 for the updater's cases below.
printf '%s\n' "$stable_before" > "$SERVER/stable/manifest.json"
ln -sfn ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb"
echo
echo "7. update_canary.sh"
@@ -286,6 +299,17 @@ check "without a version it builds nothing and names both choices" \
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
# A package handed in is gated and published as it is, so it has to be
# the release named, before anything is gated at all.
fake_deb 0.2.6.3 "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb"
check "a package handed in that says it is another version is refused" \
bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/ekklesia-classroom_0.2.6.2_amd64.deb' 2>&1); \
[[ \$? -ne 0 ]] && grep -q 'says it is 0.2.6.3' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\""
fake_deb 0.2.6.2 "$WORK/renamed.deb"
check "and so is one that is not named as the release" \
bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/renamed.deb' 2>&1); \
[[ \$? -ne 0 ]] && grep -q 'is not named' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\""
rm -f "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb" "$WORK/renamed.deb"
# ---------------------------------------------------------------------------
echo
@@ -441,8 +465,11 @@ 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")
# Since 0.2.6.5 not there and then: the reboot is handed to a service that
# waits until nobody is using the computer (classroom-reboot-when-idle).
check("and asks for the reboot the manifest wants, once nobody is using the computer",
(work / "systemctl.log").read_text().strip()
== "start --no-block ekklesia-classroom-reboot-when-idle.service")
publish("canary", "0.2.6.1")
device("canary", "0.2.7")
+39
View File
@@ -176,6 +176,32 @@ install -Dm644 \
"$ROOT/config/wallpapers/classroom-calm.png" \
"$STAGE/usr/share/backgrounds/ekklesia/classroom-calm.png"
# THE SAME PICTURE, AS A WALLPAPER PACKAGE.
#
# The look and feel names this as the default for any desktop Plasma makes
# without the layout, and Plasma only accepts a package there, never a
# file. Its one image is a link to the file above rather than a copy, so
# there is still exactly one picture, and every desktop the layout wrote
# keeps pointing at the path it always has. The name says the image's
# size, because that is how Plasma chooses between a package's images.
WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/EkklesiaClassroom"
install -Dm644 \
"$ROOT/config/wallpapers/EkklesiaClassroom/metadata.json" \
"$WALLPAPER_PACKAGE/metadata.json"
wallpaper_size="$(python3 -c '
import struct, sys
with open(sys.argv[1], "rb") as png:
header = png.read(24)
assert header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR"
print("%dx%d" % struct.unpack(">II", header[16:24]))
' "$ROOT/config/wallpapers/classroom-calm.png")"
install -d -m755 "$WALLPAPER_PACKAGE/contents" "$WALLPAPER_PACKAGE/contents/images"
ln -s /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$WALLPAPER_PACKAGE/contents/images/${wallpaper_size}.png"
@@ -193,6 +219,10 @@ install -Dm755 \
"$ROOT/scripts/ekklesia-classroom-update" \
"$STAGE/usr/local/bin/ekklesia-classroom-update"
install -Dm755 \
"$ROOT/scripts/classroom-reboot-when-idle" \
"$STAGE/usr/local/bin/classroom-reboot-when-idle"
# System configuration.
install -Dm644 \
"$ROOT/config/sddm/ekklesia-classroom.conf" \
@@ -206,6 +236,11 @@ install -Dm644 \
"$ROOT/systemd/updater/ekklesia-classroom-update.timer" \
"$STAGE/etc/systemd/system/ekklesia-classroom-update.timer"
# After an update: the reboot, once nobody is using the computer.
install -Dm644 \
"$ROOT/systemd/updater/ekklesia-classroom-reboot-when-idle.service" \
"$STAGE/etc/systemd/system/ekklesia-classroom-reboot-when-idle.service"
install -Dm644 \
"$ROOT/systemd/provision/ekklesia-classroom-provision.service" \
"$STAGE/etc/systemd/system/ekklesia-classroom-provision.service"
@@ -457,6 +492,10 @@ install -Dm755 \
"$ROOT/scripts/classroom-desktop-reset" \
"$STAGE/usr/local/bin/classroom-desktop-reset"
install -Dm755 \
"$ROOT/scripts/classroom-desktop-wallpaper" \
"$STAGE/usr/local/bin/classroom-desktop-wallpaper"
install -Dm755 \
"$ROOT/scripts/classroom-theme" \
"$STAGE/usr/local/bin/classroom-theme"
+6
View File
@@ -182,6 +182,12 @@ check "no discover or system settings" 0 \
"$(in_lab "grep -cE 'discover.desktop|systemsettings.desktop' $APPLETS")"
check "the packaged wallpaper" 1 \
"$(in_lab "grep -c 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png' $APPLETS")"
# And the one a desktop gets when nothing wrote it, which is what Tiny
# Pebbles showed: a package name, and the package there to be found.
check "a desktop nobody configured defaults to it" EkklesiaClassroom \
"$(in_lab 'sed -n "/^\[Wallpaper\]/,/^\[/s/^Image=//p" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults')"
check "and that package is the same picture" /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$(in_lab 'readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png')"
section "it will not strand a teacher"
check "no automatic lock" false "$(session_config --file kscreenlockerrc --group Daemon --key Autolock)"
+39 -9
View File
@@ -13,6 +13,8 @@
# tools/lab/dirty-upgrade [NEW.deb] [OLD.deb]
# CHECK_ARGS=--restart tools/lab/dirty-upgrade ask twice, either side
# of a logout
# tools/lab/dirty-upgrade NEW.deb dist/ekklesia-classroom_0.2.2_amd64.deb
# the day the desktops move from 0.2.2
#
# The container is restarted between the install and the session, because
# a room takes its upgrade and reboots.
@@ -38,13 +40,30 @@ docker exec "$NAME" sh -c '
# backports for Hyprland. The lab is not testing that.
systemctl mask ekklesia-classroom-provision.service >/dev/null 2>&1'
# Which version is installed, fully configured, or the rehearsal stops.
# Every step below is a question about one particular release, and a
# step that quietly left the machine on another one answers for the
# wrong release — which is how the 0.2.2 rehearsal once passed on 0.2.2.
require_installed() {
local want got
want=$(dpkg-deb --field "$1" Version)
got=$(docker exec "$NAME" dpkg-query -W -f='${Version} ${db:Status-Abbrev}' ekklesia-classroom 2>/dev/null || true)
echo " installed: $got"
if [[ "$got" != "$want ii " ]]; then
echo " $(basename "$1") DID NOT INSTALL: [$got], wanted [$want ii]" >&2
echo " Nothing after this would be a question about $want." >&2
exit 1
fi
}
step "the old classroom: $(basename "$OLD")"
# With apt, the way a room installs anything, so an old package's own
# dependencies are resolved rather than forced.
docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1
true" || true
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version}\n' ekklesia-classroom
require_installed "$OLD"
step "and everything a used machine accumulates"
docker exec -i "$NAME" bash <<'DIRT'
@@ -164,14 +183,16 @@ DIRT
step "the new classroom: $(basename "$NEW")"
docker exec "$NAME" sh -c '
systemctl unmask ekklesia-classroom-provision.service >/dev/null 2>&1 || true'
# The way ekklesia-classroom-update installs it: apt, not dpkg. The
# package conflicts with khelpcenter since 0.2.6.2, and dpkg -i refuses a
# conflict where apt resolves it — so from 0.2.2 this step used to leave
# the machine on 0.2.2 and every check below was asked of the old release.
docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' 2>&1 \
DEBIAN_FRONTEND=noninteractive apt-get install -y '/srv/classroom/${NEW#$ROOT/}' 2>&1 \
| grep -E '^\[classroom-migrate\]' || true
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
rm -f /usr/sbin/policy-rc.d
true" || true
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version} ${db:Status-Abbrev}\n' ekklesia-classroom
require_installed "$NEW"
step "and the room reboots"
"$LAB" reboot
@@ -188,8 +209,9 @@ step "and again, installed a second time over itself"
# An update that changes nothing must change nothing. The migration runs
# again here, over a machine that is already converged.
docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get install --reinstall -y -qq '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1
true" || true
require_installed "$NEW"
before=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum')
"$LAB" plasma-down >/dev/null
"$LAB" plasma-up >/dev/null
@@ -230,6 +252,14 @@ fi
theme dark >/dev/null
echo " back to $(theme show)"
step "and every desktop has the classroom's wallpaper, however it was made"
# Tiny Pebbles, 0.2.2 -> 0.2.6.4: a desktop Plasma made after the layout
# had run came up with Plasma's own wallpaper, and nothing here noticed,
# because every session this rehearsal started had its screen from the
# beginning. This makes Plasma build one by itself, and puts Tiny
# Pebbles' own file in front of a shell restart.
"$ROOT/tools/lab/wallpaper-check" || converged=1
step "and it still recovers"
"$ROOT/tools/lab/recovery-check" || converged=1
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
# When a classroom checks for updates, measured on the lab's own systemd.
#
# switched on at the time the timer fires once at its time, and not
# again that day
# switched off at the time the lab machine is really stopped across the
# time, then started: the missed check runs
# once after it boots, and does not loop
#
# A pair of test units stands in for ekklesia-classroom-update: the same
# [Timer] settings with the time moved to a minute or two from now (the
# real one would update the lab for real). The real timer's own settings,
# after installing the package, are the release gate's to check.
#
# And classroom-reboot-when-idle against real kernel input: a mouse made
# with /dev/uinput, moved and then left alone, with a harmless command in
# place of the reboot.
#
# tools/lab/update-schedule-check
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
LAB="$ROOT/tools/lab/lab"
IDLE="${IDLE_COMMAND:-/usr/local/bin/classroom-reboot-when-idle}"
LOG=/var/tmp/ekk-schedule-test.log
lab() { docker exec -i "$NAME" bash -c "$1"; }
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
}
# A timer like the real one, due at HH:MM:SS local time, with DELAY spread.
install_test_timer() { # WHEN DELAY
lab "cat > /etc/systemd/system/ekk-schedule-test.service <<'UNIT'
[Unit]
Description=Stand-in for the update check
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo \"fired \$(date +%%s) boot \$(cut -d. -f1 /proc/uptime)\" >> $LOG'
UNIT
cat > /etc/systemd/system/ekk-schedule-test.timer <<UNIT
[Unit]
Description=Stand-in for the update check
[Timer]
OnCalendar=*-*-* $1
RandomizedDelaySec=$2
Persistent=true
[Install]
WantedBy=timers.target
UNIT
rm -f $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer
systemctl daemon-reload
systemctl enable --now ekk-schedule-test.timer >/dev/null 2>&1"
}
fired() { lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1; }
local_in() { lab "date -d '+$1 seconds' +%H:%M:%S"; } # local time SECONDS from now
echo "switched on at the time"
due=$(local_in 60)
install_test_timer "$due" 0
judge "the stand-in is waiting for $due, local time" \
"lab 'systemctl list-timers ekk-schedule-test.timer --no-legend | grep -q ekk-schedule-test'"
sleep 80
judge "at $due it checked, once" '[[ $(fired) -eq 1 ]]'
sleep 60
judge "and not again (a minute later, still once)" '[[ $(fired) -eq 1 ]]'
next=$(lab "systemctl show ekk-schedule-test.timer -p NextElapseUSecRealtime --value")
tomorrow=$(lab "date -d tomorrow +%Y-%m-%d")
judge "its next check is tomorrow at $due ($next)" '[[ "$next" == *"$tomorrow $due"* ]]'
echo "switched off at the time"
due=$(local_in 90)
install_test_timer "$due" 60
lab "systemctl start ekk-schedule-test.timer; sync"
# A stamp from a previous check, as every classroom has one: last checked
# yesterday. The catch-up is decided against it.
lab "touch -d yesterday /var/lib/systemd/timers/stamp-ekk-schedule-test.timer"
echo " powering the lab off before $due"
docker stop -t 20 "$NAME" >/dev/null
sleep 150
echo " and on again, after it"
"$LAB" reboot >/dev/null 2>&1
# When the lab machine really started (inside a container /proc/uptime is
# the host's, so this is asked of docker).
booted=$(date -d "$(docker inspect -f '{{.State.StartedAt}}' "$NAME")" +%s)
judge "while it was off, nothing ran" '[[ $(lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1) -le 1 ]]'
for _ in $(seq 1 24); do [[ $(fired) -ge 1 ]] && break; sleep 5; done
judge "switched on after $due: the missed check ran after it booted ($(lab "tail -1 $LOG"))" '[[ $(fired) -eq 1 ]]'
after=$(( $(lab "awk '{print \$2}' $LOG | head -1") - booted ))
judge "$after s after it started: once it was up, within the minute of spread" '(( after >= 0 && after <= 150 ))'
sleep 90
judge "and only once: no loop after booting (90 s later, still once)" '[[ $(fired) -eq 1 ]]'
lab "systemctl disable --now ekk-schedule-test.timer >/dev/null 2>&1; rm -f /etc/systemd/system/ekk-schedule-test.* $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer; systemctl daemon-reload"
echo "reboot only when nobody is using it"
docker cp "$ROOT/scripts/classroom-reboot-when-idle" "$NAME:/opt/classroom-reboot-when-idle" >/dev/null
lab "chmod 755 /opt/classroom-reboot-when-idle; rm -f /tmp/rebooted"
[[ "$IDLE" == /usr/local/bin/* ]] && lab "test -x $IDLE" || IDLE=/opt/classroom-reboot-when-idle
cat > /tmp/update-schedule-mouse.py <<'PY'
"""A mouse made with /dev/uinput: real kernel input events. Moves for N s, then stays."""
import fcntl, os, struct, sys, time
UI_SET_EVBIT, UI_SET_RELBIT, UI_SET_KEYBIT = 0x40045564, 0x40045566, 0x40045565
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
EV_SYN, EV_KEY, EV_REL, REL_X, BTN_LEFT = 0, 1, 2, 0, 0x110
fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
for ioctl, value in ((UI_SET_EVBIT, EV_KEY), (UI_SET_KEYBIT, BTN_LEFT), (UI_SET_EVBIT, EV_REL), (UI_SET_RELBIT, REL_X)):
fcntl.ioctl(fd, ioctl, value)
os.write(fd, struct.pack("80sHHHHi", b"lab-classroom-mouse", 3, 1, 1, 1, 0) + b"\0" * (4 * 64 * 4))
fcntl.ioctl(fd, UI_DEV_CREATE)
time.sleep(1)
def event(kind, code, value):
now = time.time()
os.write(fd, struct.pack("llHHi", int(now), int(now % 1 * 1e6), kind, code, value))
end = time.monotonic() + float(sys.argv[1])
while time.monotonic() < end:
event(EV_REL, REL_X, 1); event(EV_SYN, 0, 0)
time.sleep(2)
print("mouse left alone", flush=True)
time.sleep(float(sys.argv[2]))
fcntl.ioctl(fd, UI_DEV_DESTROY)
PY
docker cp /tmp/update-schedule-mouse.py "$NAME:/opt/update-schedule-mouse.py" >/dev/null
rm -f /tmp/update-schedule-mouse.py
docker exec -d "$NAME" python3 /opt/update-schedule-mouse.py 40 90
sleep 2
# The kernel makes the device; on a classroom machine devtmpfs gives it a
# node in /dev/input. The lab's /dev is a plain tmpfs, so the node is made
# here, from what sysfs says, as devtmpfs would.
lab 'for e in /sys/class/input/event*; do
[[ "$(cat $e/device/name)" == lab-classroom-mouse ]] || continue
IFS=: read -r major minor < $e/dev
mknod /dev/input/$(basename $e) c $major $minor 2>/dev/null
echo $(basename $e) > /tmp/lab-mouse-node
done'
sleep 1
docker exec -d "$NAME" sh -c "$IDLE --idle-seconds 25 --check-seconds 2 --reboot-command touch /tmp/rebooted > /tmp/idle.log 2>&1"
sleep 35
judge "the mouse moved every 2 s for 40 s: no reboot, against a 25 s limit" "! lab 'test -e /tmp/rebooted'"
judge "it was watching real devices ($(lab 'grep -o "watching [0-9]* input devices" /tmp/idle.log'))" \
"lab 'grep -qE \"watching [1-9][0-9]* input devices\" /tmp/idle.log'"
sleep 40
judge "left alone for longer than the limit: it rebooted" "lab 'test -e /tmp/rebooted'"
lab "pkill -f '[u]pdate-schedule-mouse'; rm -f /dev/input/\$(cat /tmp/lab-mouse-node 2>/dev/null) /tmp/lab-mouse-node /tmp/rebooted /opt/update-schedule-mouse.py /opt/classroom-reboot-when-idle"
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+238
View File
@@ -0,0 +1,238 @@
#!/bin/bash
# Does every desktop show the classroom's wallpaper — including one Plasma
# makes by itself after the layout has run?
#
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop numbered 10
# and nothing configured on it, and showed Plasma's own purple wallpaper.
# The layout only reaches the desktops that exist while it runs, and a
# room whose updater reboots it at three in the morning with the
# television off has none then. The lab cannot switch a television on, so
# it makes Plasma create a desktop the other way it does, through a new
# activity: the same code in the shell, and measured to give the same
# bare containment.
#
# tools/lab/wallpaper-check against the running lab session
#
# Each answer is read off the screen, not out of a file: a configuration
# can say anything, and the question is what the room sees. The sky at the
# top of the classroom's picture is pale blue; Plasma's is purple.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
LAB="$ROOT/tools/lab/lab"
NAME="${LAB_NAME:-classroom-v2-lab}"
APPLETS=/home/classroom/.config/plasma-org.kde.plasma.desktop-appletsrc
PASS=0
FAIL=0
check() {
if [[ "$2" == "$3" ]]; then
printf ' \033[32mPASS\033[0m %-58s %s\n' "$1" "$3"
PASS=$((PASS + 1))
else
printf ' \033[31mFAIL\033[0m %-58s got [%s] want [%s]\n' "$1" "$3" "$2"
FAIL=$((FAIL + 1))
fi
}
section() { printf '\n%s\n' "$1"; }
in_lab() { docker exec "$NAME" bash -c "$1" 2>/dev/null; }
# The same, with this script's input handed through — for a program on a
# here-document. Without -i docker gives it nothing to read, and it runs
# on nothing and says nothing.
in_lab_fed() { docker exec -i "$NAME" bash -c "$1"; }
user() { "$LAB" run "$@" 2>/dev/null; }
activities() {
user busctl --user call org.kde.ActivityManager /ActivityManager/Activities \
org.kde.ActivityManager.Activities "$@"
}
# What is on the screen at the top middle, where the classroom's picture
# is sky and Plasma's is purple: "classroom", "plasma", or the colour.
screen() {
"$LAB" outer grim -t ppm - 2>/dev/null | python3 -c '
import sys
data = sys.stdin.buffer.read()
fields, at = [], 0
while len(fields) < 4:
while data[at:at + 1].isspace():
at += 1
end = at
while not data[end:end + 1].isspace():
end += 1
fields.append(data[at:end])
at = end
width, height = int(fields[1]), int(fields[2])
pixels = data[at + 1:]
total = [0, 0, 0]
count = 0
for y in range(20, 140, 4):
for x in range(width * 2 // 5, width * 3 // 5, 8):
offset = (y * width + x) * 3
for channel in range(3):
total[channel] += pixels[offset + channel]
count += 1
r, g, b = (value / count for value in total)
if b > r + 25 and g > r + 10:
print("classroom")
elif r > g + 25 and b > g + 25:
print("plasma")
else:
print(f"rgb({r:.0f},{g:.0f},{b:.0f})")
'
}
# The shell writes its file a few seconds after a change, not at once.
settle() { sleep "${1:-35}"; }
# The shell's file, read as groups rather than grepped, so nothing here
# can pick a line out of the wrong containment.
#
# applets panels each panel's number, its applets' numbers and plugins
# and their order. Not the panel's text: the shell adds
# keys of its own to applets as soon as it runs (popup
# sizes, the volume's migration), and that is Plasma,
# not the repair. tests/ checks the repair's own
# output byte for byte.
# applets desktops the desktop containments as "id:image" or "id:none",
# found by plugin and never by number
applets() {
in_lab_fed "python3 - $1 $APPLETS" <<'PY'
import re, sys
mode, path = sys.argv[1], sys.argv[2]
groups, name = {}, None
for line in open(path).read().splitlines():
if line.startswith("[") and line.endswith("]"):
name = line
groups[name] = []
elif name and line:
groups[name].append(line)
def top(cid):
return groups.get(f"[Containments][{cid}]", [])
ids = [m.group(1) for g in groups for m in [re.match(r"^\[Containments\]\[(\d+)\]$", g)] if m]
if mode == "panels":
for c in ids:
if "plugin=org.kde.panel" not in top(c):
continue
applets = sorted(
(int(m.group(1)), line[len("plugin=org.kde.plasma."):])
for g in groups
for m in [re.match(r"^\[Containments\]\[%s\]\[Applets\]\[(\d+)\]$" % c, g)] if m
for line in groups[g] if line.startswith("plugin=")
)
order = [l[len("AppletOrder="):] for l in groups.get(f"[Containments][{c}][General]", [])
if l.startswith("AppletOrder=")]
print(f"panel {c}:", " ".join(f"{n}={name}" for n, name in applets), "order", *order)
else:
image = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
out = []
for c in ids:
if {"plugin=org.kde.plasma.folder", "plugin=org.kde.desktopcontainment"} & set(top(c)):
wallpaper = groups.get(f"[Containments][{c}][Wallpaper][org.kde.image][General]", [])
out.append(f"{c}:{'image' if image in wallpaper else 'none'}")
print(" ".join(out))
PY
}
panel_groups() { applets panels; }
desktops() { applets desktops; }
last_repair() {
user journalctl --user -u plasma-plasmashell.service --no-pager -o cat \
| grep '^classroom-desktop-wallpaper:' | tail -1
}
restart_shell() {
user systemctl --user restart plasma-plasmashell.service
for _ in $(seq 1 30); do
in_lab 'pgrep -x plasmashell >/dev/null' && break
sleep 1
done
sleep 10
}
section "the package"
check "the look and feel's default names a package" EkklesiaClassroom \
"$(in_lab 'sed -n "/^\[Wallpaper\]/,/^\[/s/^Image=//p" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults')"
check "which is installed, and is the classroom's picture" /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$(in_lab 'readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png')"
check "the repair runs before every shell start" 1 \
"$(in_lab 'grep -c "^ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" /etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf')"
section "the session as it came up"
check "the room shows the classroom's picture" classroom "$(screen)"
original=$(activities CurrentActivity | cut -d'"' -f2)
section "a desktop Plasma makes by itself (Tiny Pebbles' television)"
panels_before=$(panel_groups)
late=$(activities AddActivity s "Late screen" | cut -d'"' -f2)
activities SetCurrentActivity s "$late" >/dev/null
settle
now=$(desktops)
check "Plasma made a second desktop" 2 "$(wc -w <<< "$now")"
check "and wrote no wallpaper into it, as on Tiny Pebbles" 1 "$(grep -o ':none' <<< "$now" | wc -l)"
check "the room still shows the classroom's picture" classroom "$(screen)"
section "the shell restarted, without a login"
restart_shell
check "the repair wrote it in" "classroom-desktop-wallpaper: the wallpaper is now configured on desktop $(tr ' ' '\n' <<< "$now" | cut -d: -f1 | paste -sd, | sed 's/,/, /g')" \
"$(last_repair)"
check "every desktop now names the picture" 0 "$(desktops | grep -o ':none' | wc -l)"
check "the panel keeps its numbers, applets and order" "$panels_before" "$(panel_groups)"
check "the room shows the classroom's picture" classroom "$(screen)"
restart_shell
check "a second restart finds nothing to do" \
"classroom-desktop-wallpaper: desktop $(desktops | tr ' ' '\n' | cut -d: -f1 | paste -sd, | sed 's/,/, /g'): the classroom wallpaper already" \
"$(last_repair)"
check "and still does" "$panels_before" "$(panel_groups)"
activities SetCurrentActivity s "$original" >/dev/null
activities RemoveActivity s "$late" >/dev/null
sleep 3
section "Tiny Pebbles' own file: desktop 10, panel 1, no wallpaper"
user systemctl --user stop plasma-plasmashell.service
in_lab_fed "python3 - '$original' $APPLETS" <<'PY'
import sys
activity, path = sys.argv[1], sys.argv[2]
applets = ["quicklaunch", "panelspacer", "icontasks", "panelspacer",
"networkmanagement", "volume", "notifications", "digitalclock"]
out = ["[Containments][1]", "activityId=", "formfactor=2", "immutability=1",
"lastScreen=0", "location=4", "plugin=org.kde.panel",
"wallpaperplugin=org.kde.image", ""]
for number, applet in enumerate(applets, start=2):
out += [f"[Containments][1][Applets][{number}]", "immutability=1",
f"plugin=org.kde.plasma.{applet}", ""]
out += ["[Containments][1][Applets][4][Configuration][General]",
"launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop",
"sortingStrategy=1", "",
"[Containments][1][General]", "AppletOrder=2;3;4;5;6;7;8;9", "",
"[Containments][10]", f"activityId={activity}", "formfactor=0",
"immutability=1", "lastScreen=0", "location=0",
"plugin=org.kde.plasma.folder", "wallpaperplugin=org.kde.image", "",
"[Containments][10][General]", 'positions={"1920x1080":[]}', ""]
open(path, "w").write("\n".join(out))
PY
in_lab "chown classroom:classroom $APPLETS"
panels_before=$(panel_groups)
user systemctl --user start plasma-plasmashell.service
sleep 12
check "the repair found desktop 10 and wrote it" \
"classroom-desktop-wallpaper: the wallpaper is now configured on desktop 10" "$(last_repair)"
check "desktop 10 names the picture" "10:image" "$(desktops)"
check "the panel is still containment 1, applets 2 to 9, in order" "$panels_before" "$(panel_groups)"
check "the room shows the classroom's picture" classroom "$(screen)"
section "and at the next login"
"$LAB" plasma-down >/dev/null
"$LAB" plasma-up >/dev/null
settle 10
check "the reset left the repair nothing to do" \
"classroom-desktop-wallpaper: no desktop configuration yet; the layout writes the wallpaper" \
"$(last_repair)"
check "the layout rebuilt one desktop with the picture" 1 "$(desktops | grep -o ':image' | wc -l)"
check "the room shows the classroom's picture" classroom "$(screen)"
printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# THE RELEASE GATE: may this exact file go to a classroom?
#
# tools/release-gate.sh PACKAGE.deb [FROM_VERSION...]
#
# Runs tools/validate-trixie-release.sh against PACKAGE as
#
# * a fresh install;
# * an upgrade from what canary serves today, on a canary room;
# * an upgrade from what stable serves today, on a stable room;
# * an upgrade from each FROM_VERSION, on a stable room — the release a
# room is really still on, which can be several behind stable.
#
# The releases it upgrades from are downloaded over HTTPS the way a
# classroom downloads them, and each is checked against the SHA-256 its
# manifest publishes, or, for a FROM_VERSION, against the version dpkg
# reads out of it. Nothing here needs a credential or writes to the
# update server, so a CI runner can run it on code nobody has reviewed.
#
# Every run is one line in RELEASE_GATE_REPORT (default: printed only),
# so the result can be attached to the release it describes.
#
# It stops at the first failure: a gate that carries on reports a pass
# for a machine it never finished building.
set -euo pipefail
# The fetches below run in $(...), which otherwise ignores set -e: a
# manifest that failed to download would go on to compare an empty hash
# with an empty hash, and match.
shopt -s inherit_errexit
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT/tools/release-lib.sh"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 PACKAGE.deb [FROM_VERSION...]" >&2
exit 2
fi
candidate="$(realpath "$1")"
shift
extra_from=("$@")
[[ -f "$candidate" ]] || { echo "Package not found: $candidate" >&2; exit 1; }
version="$(release_package_version "$candidate")"
release_require_version "$version"
# The name a release is published under is the version dpkg reads out of
# it. A file called 0.2.6.6 that says it is 0.2.6.5 is the wrong file.
if [[ "$(basename "$candidate")" != "$(release_package_name "$version")" ]]; then
echo "$(basename "$candidate") says it is $version." >&2
exit 1
fi
hash="$(sha256sum "$candidate" | awk '{print $1}')"
report="${RELEASE_GATE_REPORT:-}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
record() {
printf '%s\n' "$1"
[[ -z "$report" ]] || printf '%s\n' "$1" >> "$report"
}
[[ -z "$report" ]] || : > "$report"
record "release gate for $(basename "$candidate")"
record "sha256 $hash"
record "started $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# A channel's current package, fetched as a classroom fetches it and
# proven to be what the manifest promises.
fetch_channel() {
local channel="$1" manifest from from_hash name got
manifest="$(curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$channel/manifest.json")"
read -r from from_hash < <(python3 -c 'import json, sys
m = json.loads(sys.argv[1])
print(m["version"], m["sha256"])' "$manifest")
release_require_version "$from"
[[ "$from_hash" =~ ^[0-9a-f]{64}$ ]]
name="$(release_package_name "$from")"
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
got="$(sha256sum "$work/$name" | awk '{print $1}')"
if [[ "$got" != "$from_hash" ]]; then
echo "$channel serves $name with sha256 $got; its manifest says $from_hash" >&2
return 1
fi
printf '%s\n' "$work/$name"
}
# A published release by version, for the rooms still on an older one.
fetch_version() {
local from="$1" name
release_require_version "$from"
name="$(release_package_name "$from")"
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
if [[ "$(release_package_version "$work/$name")" != "$from" ]]; then
echo "$name does not say it is $from" >&2
return 1
fi
printf '%s\n' "$work/$name"
}
run() {
local label="$1" started
shift
started=$SECONDS
echo
echo "== $label"
"$@"
record "PASS $label ($(( (SECONDS - started) / 60 )) min)"
}
# A new machine is a stable room: that is what a fresh device.conf says,
# so that is the manifest its updater is asked about.
run "fresh install of $version (a new machine, on stable)" \
env VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$candidate"
for channel in canary stable; do
previous="$(fetch_channel "$channel")"
from="$(release_package_version "$previous")"
if ! release_version_gt "$version" "$from"; then
echo "$channel is on $from; $version is not newer, so no room could take it." >&2
exit 1
fi
run "upgrade $from -> $version on a $channel room ($channel serves $from)" \
env VALIDATION_CHANNEL="$channel" \
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
done
for from in "${extra_from[@]}"; do
previous="$(fetch_version "$from")"
run "upgrade $from -> $version on a stable room" \
env VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
done
record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the release gate"
+89
View File
@@ -117,6 +117,13 @@ 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)"
# The release this is an upgrade FROM, proven rather than assumed: an
# install that silently did not happen makes every check below a
# check of a fresh install instead.
[[ "$previous_version" == "$(dpkg-deb --field "$previous" Version)" ]]
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)" == "ii " ]]
echo "installed the previous release, ekklesia-classroom $previous_version"
fi
run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
@@ -130,6 +137,7 @@ run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
# for ever.
candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)"
[[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]]
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)" == "ii " ]]
[[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]]
echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}"
@@ -145,6 +153,20 @@ systemctl is-enabled NetworkManager.service >/dev/null
systemctl is-enabled cups.service >/dev/null
! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1
# The update check: daily at 03:00, caught up after a night switched off,
# and still so after this install or upgrade. The reboot after an update
# waits until nobody is using the computer, in a unit nothing enables.
systemctl is-enabled ekklesia-classroom-update.timer >/dev/null
timer=/etc/systemd/system/ekklesia-classroom-update.timer
grep -qx 'OnCalendar=\*-\*-\* 03:00:00' "$timer"
grep -qx 'Persistent=true' "$timer"
! grep -qE '^(OnBootSec|OnStartupSec|OnUnitActiveSec|OnUnitInactiveSec|OnActiveSec)=' "$timer"
! ls /etc/systemd/system/ekklesia-classroom-update.timer.d/*.conf >/dev/null 2>&1
test -x /usr/local/bin/classroom-reboot-when-idle
[[ "$(systemctl is-enabled ekklesia-classroom-reboot-when-idle.service 2>/dev/null || true)" == "static" ]]
! grep -q '"systemctl", "reboot"' /usr/local/bin/ekklesia-classroom-update
grep -q 'ekklesia-classroom-reboot-when-idle.service' /usr/local/bin/ekklesia-classroom-update
# The audio policy reaches a session, and is ordered after the server it
# actually talks to. Ordering it only after pipewire and wireplumber is
# what let it race PipeWire's PulseAudio server at boot, fail its one
@@ -485,6 +507,71 @@ if [[ -n "$previous" ]]; then
grep -q 'var PRESENTATIONS = false;' \
/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
# TINY PEBBLES, 0.2.2 -> 0.2.6.4: THE DESKTOP WITH NO WALLPAPER.
#
# The room's desktop was containment 10, made by Plasma after the
# layout had run, with nothing configured on it; it showed Plasma's
# own wallpaper. What a desktop gets when nothing wrote it is the look
# and feel's default, and that has to be a package Plasma can find.
lnf=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults
[[ "$(sed -n '/^\[Wallpaper\]/,/^\[/s/^Image=//p' "$lnf")" == EkklesiaClassroom ]]
grep -q '"Id": "EkklesiaClassroom"' /usr/share/wallpapers/EkklesiaClassroom/metadata.json
[[ "$(readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png)" \
== /usr/share/backgrounds/ekklesia/classroom-calm.png ]]
grep -qx 'ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper' \
/etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
# And the repair the package installed, on that room's own file: the
# panel built with no screen took 1 and its applets 2 to 9.
wallpaper_home=$(mktemp -d)
cat > "$wallpaper_home/plasma-org.kde.plasma.desktop-appletsrc" <<'APPLETS'
[Containments][1]
formfactor=2
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][1][Applets][2]
plugin=org.kde.plasma.quicklaunch
[Containments][1][Applets][9]
plugin=org.kde.plasma.digitalclock
[Containments][1][General]
AppletOrder=2;3;4;5;6;7;8;9
[Containments][10]
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
APPLETS
applets="$wallpaper_home/plasma-org.kde.plasma.desktop-appletsrc"
panel_before=$(sed -n '/^\[Containments\]\[1\]/,/^$/p' "$applets")
! XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper --check >/dev/null
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper \
| grep -q 'configured on desktop 10'
sed -n '/^\[Containments\]\[10\]\[Wallpaper\]\[org.kde.image\]\[General\]$/,/^$/p' "$applets" \
| grep -qx 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png'
[[ "$(sed -n '/^\[Containments\]\[1\]/,/^$/p' "$applets")" == "$panel_before" ]]
[[ "$(grep -c '^\[Containments\]\[[0-9]*\]$' "$applets")" -eq 2 ]]
# Twice changes nothing, and a room that was already right is not
# written at all.
repaired=$(sha256sum < "$applets")
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper \
| grep -q 'the classroom wallpaper already'
[[ "$(sha256sum < "$applets")" == "$repaired" ]]
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper --check >/dev/null
rm -rf "$wallpaper_home"
# And a room that was carried through the experiment on its own
# channel comes home, because that channel is the obsolete one now.
sed -i 's/^CHANNEL=.*/CHANNEL=classroom-v2/' /etc/ekklesia-classroom/device.conf
@@ -505,6 +592,8 @@ if [[ -n "$previous" ]]; then
run_logged candidate-reinstall 900 \
apt-get install --reinstall -y -qq /packages/candidate.deb
[[ "$(dpkg-query -W -f='${Version} ${db:Status-Abbrev}' ekklesia-classroom)" \
== "$candidate_version ii " ]]
# An install that changes nothing must change nothing.
for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do
+46 -36
View File
@@ -12,9 +12,12 @@ source "$ROOT/tools/release-lib.sh"
# 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
if [[ $# -lt 1 || $# -gt 2 ]]; then
current="$(release_channel_version canary || true)"
echo "Usage: $0 VERSION" >&2
echo "Usage: $0 VERSION [PACKAGE.deb]" >&2
echo >&2
echo "With PACKAGE.deb, that file is gated and published and nothing is" >&2
echo "built, so the file that was tested is the file that is published." >&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
@@ -28,62 +31,69 @@ fi
version="$1"
package_name="$(release_package_name "$version")"
package_path="$ROOT/dist/$package_name"
prebuilt="${2:-}"
echo "Current canary: $(release_channel_version canary || echo unknown)"
echo "New canary: $version"
echo
# RELEASE_GATE_FROM="0.2.2" adds the upgrade from an older release that
# rooms are still on to the gate, beside canary's and stable's own.
#
# 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"
# ONE FILE, FROM THE GATE TO THE ROOM.
#
# Given a package, that package is what is gated and published, and
# nothing is rebuilt: a build is not byte-for-byte repeatable, so a second
# build is a second, untested file. Without one, it is built here.
if [[ -n "$prebuilt" ]]; then
package_path="$(realpath "$prebuilt")"
# 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 "$RELEASE_LOCAL/canary/manifest.json" ]]; then
previous_package_name="$(python3 -c \
'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")
if [[ "$(basename "$package_path")" != "$package_name" ]]; then
echo "$prebuilt is not named $package_name." >&2
exit 1
fi
if [[ "$(release_package_version "$package_path")" != "$version" ]]; then
echo "$prebuilt says it is $(release_package_version "$package_path"), not $version." >&2
exit 1
fi
echo "Publishing the package already built: $package_path"
else
"$ROOT/tools/build-release.sh" "$version"
fi
"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}"
# A fresh install, and the upgrade from whatever canary and stable serve
# today, each on a room of that channel. 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. See tools/release-gate.sh.
hash="$(sha256sum "$package_path" | awk '{print $1}')"
# 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
"$ROOT/tools/release-gate.sh" "$package_path" ${RELEASE_GATE_FROM:-}
# Checked again after the gate, which takes long enough for somebody else
# to have published in the meantime.
# to have published in the meantime — or for the file to have changed.
release_check_new_version "$version"
hash="$(sha256sum "$package_path" | awk '{print $1}')"
if [[ "$(sha256sum "$package_path" | awk '{print $1}')" != "$hash" ]]; then
echo "$package_path changed while it was being gated. Nothing was published." >&2
exit 1
fi
mkdir -p "$RELEASE_LOCAL/packages"
cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name"
if [[ "$(sha256sum "$RELEASE_LOCAL/packages/$package_name" | awk '{print $1}')" != "$hash" ]]; then
echo "$RELEASE_LOCAL/packages/$package_name is not the file that passed the gate." >&2
exit 1
fi
echo
echo "Publishing package..."
release_upload_package "$RELEASE_LOCAL/packages/$package_name"
+24 -1
View File
@@ -9,7 +9,23 @@ 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
#
# ./update_stable.sh VERSION SHA256 the release that was approved, by
# the checksum it was approved as
# ./update_stable.sh VERSION the release, whatever file it is
# ./update_stable.sh whatever canary is on
#
# With SHA256, anything but that exact file is refused. The checksum is
# the one written down when the release went to canary and passed its
# physical checks, so the approval and the promotion name the same bytes.
expected_hash="${2:-}"
if [[ -n "$expected_hash" && ! "$expected_hash" =~ ^[0-9a-f]{64}$ ]]; then
echo "Not a SHA-256 checksum: $expected_hash" >&2
exit 1
fi
if [[ $# -ge 1 ]]; then
version="$1"
else
if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
@@ -50,6 +66,13 @@ fi
hash="$(sha256sum "$package_path" | awk '{print $1}')"
if [[ -n "$expected_hash" && "$hash" != "$expected_hash" ]]; then
echo "$package_name is not the file that was approved:" >&2
echo " approved: $expected_hash" >&2
echo " here: $hash" >&2
exit 1
fi
echo "Promoting $version to STABLE (from ${current:-nothing})"
# Refuses unless the server already holds exactly this package.