@vibelord/lanes 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -0,0 +1,972 @@
1
+ #!/usr/bin/env python3
2
+ """A server update after its commit point, and the record of every update run (plan W9 U2, LNS-90). Python 3 stdlib.
3
+
4
+ python3 libexec/update.py post-commit --state DIR --tree DIR --log FILE [--before DIR] [--started UTC] [--step NAME=RC …]
5
+ python3 libexec/update.py record --result refused|failed|partial --state DIR --tree DIR --log FILE [--before DIR]
6
+ [--started UTC] [--step NAME=RC …]
7
+ python3 libexec/update.py lock --state DIR --fd N --mode update|install
8
+
9
+ `install/host-install.sh --update-only` (what `npx @vibelord/lanes@<release> --server --update-only` runs) installs a
10
+ release in three phases. pre-commit: preflight (prerequisites, the user manager, Docker, and a release installed
11
+ already: an update restarts what a full server install enabled), the pinned binaries (sha256-verified), terminfo, the
12
+ catalog — a failure there leaves the installed release running as it was. commit: `install/services.py --install`
13
+ (every unit, `current.previous`, then the flip of `current`; W9 U1). post-commit: this module, run from the tree just
14
+ installed. Its steps run in this order, each whatever the ones before it returned, each bounded by STEP_TIMEOUT seconds
15
+ (its whole process group is stopped when it does not finish):
16
+ systemctl --user daemon-reload · lanes sync · systemctl --user restart lanes-statusd.service ·
17
+ systemctl --user try-restart <unit> for each optional service in TRY_RESTART (restarted only when it runs) ·
18
+ lanes install-container <name> for each container `lanes sync` recorded with a home volume (containers.json) ·
19
+ lanes reload-views (the viewports; sidebars swap by themselves, W9 U5) · lanes ensure --if-layout-changed (W9 U6).
20
+ Step output goes to the log (`update/last.log`); stdout gets one line per step and a summary.
21
+
22
+ The record, `<state>/update/last.json` (DM-LANES-UPDATE-RECORD), is one JSON object {checked_at, latest, installed,
23
+ previous, result, steps: [{name, rc[, warning]}], log}. result: `ok` every phase and step exited 0 or only warned;
24
+ `partial` the release is installed but a step after the commit failed (also while the post-commit runs: it is written
25
+ before the first step); `refused` a prerequisite, no installed release, or another install holding the install lock
26
+ said no and nothing changed; `failed` nothing was installed. A warning (WARN_RC) is a step's own report of a condition
27
+ the update neither caused nor can mend — `lanes sync` exiting 1 for a board with an error — recorded with its rc and
28
+ `"warning": true`, never a failure. Keys it does not own (a rollback's mark, plan W9 U3) are kept. host-install.sh
29
+ writes `refused`, `failed` and a `partial` commit through `record`; `post-commit` writes its own.
30
+
31
+ The run lock, `<state>/update/.lock` (fix cycle 1 of W9 U2): every server install and update takes it before it writes
32
+ anything — the log and the record included — and holds it to its end, so two runs never interleave a post-commit or
33
+ race on the record. host-install.sh opens the file on a descriptor of its own and `lock` takes an flock(2) on that
34
+ descriptor without waiting: the lock belongs to the open file, so it is held for as long as the installer's shell
35
+ lives (by nothing it starts: the shell closes the descriptor for its long-running children, and run_step closes every
36
+ descriptor but the step's stdio), and a crash releases it. A run that finds it held writes nothing and exits 3. `lanes
37
+ update` (plan W9 U3) runs host-install.sh, so it must not hold this lock while that runs; a rollback that calls
38
+ post_commit() itself takes it with take_run_lock(). Exit: post-commit 0 ok, 4 partial; record 0; lock 0 taken, 3 held
39
+ by another run (one line on stdout says which); 2 usage.
40
+
41
+ `lanes update` (plan W9 U3, LNS-91; update_main, below) is what lanes-update.timer runs every 30 min (`--timer`): one
42
+ at a time (the flock `<state>/locks/update.lock`), from an npm-installed server only (`current` names a tree the npm
43
+ wrapper staged). A last run that ended `partial` gets its post-commit again first, from the tree `current` names; then,
44
+ unless a check answered less than INTERVAL ago (`--now` asks anyway), the registry is asked which release the channel's
45
+ dist-tag names (`latest`, or `lanes-stable` with `channel=stable` in ~/.config/lanes/update.conf). A release newer than
46
+ the installed one, and not the one a rollback left (`bad`), is installed with the npx the npm wrapper recorded at
47
+ install (`<state>/update/npx`): `npx --yes @vibelord/lanes@<release> --server --update-only`, with the run lock
48
+ released meanwhile (host-install.sh takes it), the check handed over in LANES_UPDATE_LATEST and LANES_UPDATE_CHECKED_AT,
49
+ and the settings the installed units carry (LANES_WORKSPACES, LANES_THROTTLE_*, LANES_NOTIFY_CONF) given again when this
50
+ environment lacks them, so a timer's update renders the units as the install that set them up did. `--rollback`
51
+ installs the tree `current.previous` names again in place (install/services.py of this tree; no network), marks the
52
+ release it left `bad` in the same write that records the flip (`partial` until the post-commit has run; SIGINT, SIGTERM
53
+ and SIGHUP wait for the flip and that write), then runs its post-commit; `--status` prints the record. Every check is
54
+ recorded in the record's `check` (DM-LANES-UPDATE-RECORD) — when, what the registry said and what came of it — and a
55
+ rollback's `bad`, a re-run's `rerun`; the install's own keys stay the installer's. `lanes doctor`'s update line
56
+ (doctor_line) reads the record and whether lanes-update.timer is enabled (its timers.target.wants link)."""
57
+ import argparse
58
+ import calendar
59
+ import contextlib
60
+ import fcntl
61
+ import importlib.util
62
+ import json
63
+ import os
64
+ from pathlib import Path
65
+ import re
66
+ import shlex
67
+ import signal
68
+ import stat
69
+ import subprocess
70
+ import sys
71
+ import time
72
+ import traceback
73
+
74
+ import lanes_lib as L
75
+
76
+ STEP_TIMEOUT = 300 # seconds per post-commit step; then its process group is stopped and the step is rc 124
77
+ STOP_WAIT = 5 # seconds between SIGTERM and SIGKILL of a step's process group, and after it
78
+ # The optional services a post-commit restarts only when they run (`systemctl try-restart`), in this order. Each keeps
79
+ # its enable state: an update never switches on a unit the operator left off (economy, notify, web: operator gates).
80
+ TRY_RESTART = ('lanes-economy.service', 'lanes-notify.service', 'lanes-enrold.service', 'lanes-mobile.service',
81
+ 'lanes-web.service', 'lanes-studio-host.service')
82
+ RESULTS = ('ok', 'partial', 'refused', 'failed')
83
+ OWNED = ('checked_at', 'latest', 'installed', 'previous', 'result', 'steps', 'log') # the keys every record rewrites
84
+ PARTIAL = 4 # post-commit's exit status when a step failed (host-install.sh --update-only's as well)
85
+ REFUSED = 3 # `lock`'s exit status when another run holds the run lock (host-install.sh's refusal)
86
+ # The steps whose exit status here is their own report of a condition the update neither caused nor can mend: a
87
+ # warning, recorded with its rc and `"warning": true`, which leaves the result as it is (plan W9 U2: `lanes sync ||
88
+ # warn`). `lanes sync` exits 1 whenever one board's registry has an error — a container docker no longer knows, a
89
+ # terminals.json it cannot read — after publishing every other board and keeping that one's previous lanes (bin/lanes
90
+ # cmd_sync: `return 1 if failed`); counted as a failure, it would make every update `partial` for as long as such a
91
+ # board exists, and `lanes update` re-run the whole post-commit on every tick (fix cycle 1 of W9 U2). Any other status
92
+ # of the step — 124 no answer, 127 could not start, 128+N a signal — is a failure like any other.
93
+ WARN_RC = {'sync': 1}
94
+ LOCK = 'update/.lock' # the run lock, under the state directory
95
+ MODES = ('update', 'install')
96
+ RELEASE = re.compile(r'[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.+-]+)?\Z')
97
+ UTC = re.compile(r'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\Z')
98
+ STEP = re.compile(r'([a-z][a-z0-9-]*)=([0-9]{1,3})\Z')
99
+
100
+
101
+ def record_path(state):
102
+ return Path(state) / 'update' / 'last.json'
103
+
104
+
105
+ def lock_path(state):
106
+ return Path(state) / LOCK
107
+
108
+
109
+ def utc(at=None):
110
+ return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(at))
111
+
112
+
113
+ def step_entry(step):
114
+ """A record's step from (name, rc) or (name, rc, warning): {name, rc}, plus `"warning": true` for a warning."""
115
+ name, rc, *rest = step
116
+ entry = {'name': name, 'rc': int(rc)}
117
+ if rest and rest[0]:
118
+ entry['warning'] = True
119
+ return entry
120
+
121
+
122
+ def failed_steps(steps):
123
+ """The names of the steps that failed: a non-zero rc that is no warning."""
124
+ return [entry['name'] for entry in map(step_entry, steps) if entry['rc'] and not entry.get('warning')]
125
+
126
+
127
+ def warned_steps(steps):
128
+ return [entry['name'] for entry in map(step_entry, steps) if entry.get('warning')]
129
+
130
+
131
+ def installed_tree(home=None):
132
+ """The existing tree the `current` link names now (resolved), else None."""
133
+ try:
134
+ tree = L.current_link(home).resolve(strict=True)
135
+ except (KeyError, OSError, RuntimeError):
136
+ return None
137
+ return tree if tree.is_dir() else None
138
+
139
+
140
+ def write_record(state, result, steps, tree, before, log, started=None, environ=None, home=None, keys=None):
141
+ """Write `<state>/update/last.json` atomically -> the document. `steps` is [(name, rc)] or [(name, rc, warning)] in
142
+ run order; `tree` the tree this run installs, `before` the tree `current` named when it started (None: none). latest =
143
+ $LANES_UPDATE_LATEST (the release `lanes update` found, plan W9 U3) when it is a release, else this tree's VERSION;
144
+ checked_at = $LANES_UPDATE_CHECKED_AT when it is a UTC stamp, else `started`, else now; installed = the VERSION of
145
+ the tree `current` names now; previous = the VERSION of `before`. `keys`: keys outside OWNED set in the same write,
146
+ None removing one (a rollback's `bad` and `rerun`, written with the record of its flip: fix cycle 1 of W9 U3)."""
147
+ if result not in RESULTS:
148
+ raise ValueError(f'unknown result {result!r}')
149
+ if any(key in OWNED for key in keys or {}):
150
+ raise ValueError(f'keys the installer owns: {sorted(set(keys) & set(OWNED))}')
151
+ environ = os.environ if environ is None else environ
152
+ latest, checked = environ.get('LANES_UPDATE_LATEST', ''), environ.get('LANES_UPDATE_CHECKED_AT', '')
153
+ installed = installed_tree(home)
154
+ document = {
155
+ 'checked_at': checked if UTC.match(checked) else started if started and UTC.match(started) else utc(),
156
+ 'latest': latest if RELEASE.match(latest) else L.tree_version(tree),
157
+ 'installed': L.tree_version(installed) if installed else None,
158
+ 'previous': L.tree_version(before) if before else None,
159
+ 'result': result,
160
+ 'steps': [step_entry(step) for step in steps],
161
+ 'log': str(log),
162
+ }
163
+ old = L.read_json(record_path(state))
164
+ if isinstance(old, dict):
165
+ document.update((key, value) for key, value in old.items() if key not in OWNED)
166
+ for key, value in (keys or {}).items():
167
+ if value is None:
168
+ document.pop(key, None)
169
+ else:
170
+ document[key] = value
171
+ L.atomic_write(record_path(state), json.dumps(document, indent=2) + '\n')
172
+ return document
173
+
174
+
175
+ def stop(child):
176
+ """Stop a step's whole process group: SIGTERM, a bounded wait, SIGKILL, one more bounded wait."""
177
+ for signum in (signal.SIGTERM, signal.SIGKILL):
178
+ try:
179
+ os.killpg(child.pid, signum)
180
+ except (ProcessLookupError, PermissionError):
181
+ return
182
+ try:
183
+ child.wait(STOP_WAIT)
184
+ return
185
+ except subprocess.TimeoutExpired:
186
+ continue
187
+
188
+
189
+ def run_step(argv, log, timeout):
190
+ """Run one step with its output appended to `log` (an open file) -> its exit status: 128+N when signal N ended it,
191
+ 124 when it did not finish in `timeout` seconds (its process group is stopped), 127 when it could not start.
192
+ Its own session, so a timeout stops its children too; stdin is /dev/null."""
193
+ log.write(f'== {shlex.join(argv)}\n')
194
+ log.flush()
195
+ try:
196
+ child = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT,
197
+ start_new_session=True)
198
+ except OSError as error:
199
+ log.write(f'-> could not start: {error}\n')
200
+ log.flush()
201
+ return 127
202
+ try:
203
+ rc = child.wait(timeout)
204
+ except subprocess.TimeoutExpired:
205
+ stop(child)
206
+ log.write(f'-> no answer in {timeout:g} s: stopped\n')
207
+ log.flush()
208
+ return 124
209
+ except BaseException:
210
+ stop(child)
211
+ raise
212
+ rc = 128 - rc if rc < 0 else rc
213
+ log.write(f'-> exit {rc}\n')
214
+ log.flush()
215
+ return rc
216
+
217
+
218
+ def recorded_containers(state):
219
+ """The containers `lanes sync` recorded with a home volume in `<state>/containers.json`, sorted. install-container
220
+ writes into that volume, so a container docker said does not exist (no home) is left out: sync reported it."""
221
+ document = L.read_json(Path(state) / 'containers.json', {})
222
+ if not isinstance(document, dict):
223
+ return []
224
+ return sorted(name for name, info in document.items()
225
+ if L.IDENT.match(name) and isinstance(info, dict) and info.get('home'))
226
+
227
+
228
+ def describe(rc, timeout):
229
+ return {124: f'no answer in {timeout:g} s', 127: 'could not start'}.get(rc, f'exit {rc}')
230
+
231
+
232
+ def interrupted_rc(error):
233
+ return error.code if isinstance(error, SystemExit) and isinstance(error.code, int) else 130
234
+
235
+
236
+ def count(names, noun):
237
+ return f"{len(names)} {noun}{'' if len(names) == 1 else 's'}"
238
+
239
+
240
+ def post_commit(tree, state, log, steps=(), before=None, started=None, out=None, run=None, timeout=STEP_TIMEOUT,
241
+ systemctl='systemctl', environ=None, home=None):
242
+ """Run the post-commit steps from `tree` (whose bin/lanes runs them) -> the record written. `steps` are the phases
243
+ that ran before, [(name, rc)]. The record says `partial` before the first step, so a run killed half-way stays
244
+ `partial`; after the last step (or an interrupt: a SystemExit or KeyboardInterrupt is recorded as the step's rc,
245
+ then raised again) it says `ok` when every phase and step exited 0 or only warned (WARN_RC), else `partial`."""
246
+ out = sys.stdout if out is None else out
247
+ run = run_step if run is None else run
248
+ steps = list(steps)
249
+ lanes = str(Path(tree) / 'bin' / 'lanes')
250
+ write_record(state, 'partial', steps, tree, before, log, started, environ, home)
251
+ finished = False
252
+ Path(log).parent.mkdir(parents=True, exist_ok=True)
253
+ with open(log, 'a') as handle:
254
+ def step(name, argv):
255
+ try:
256
+ rc = run(argv, handle, timeout)
257
+ except (KeyboardInterrupt, SystemExit) as error:
258
+ steps.append((name, interrupted_rc(error)))
259
+ print(f'FAIL {name} (interrupted)', file=out, flush=True)
260
+ raise
261
+ warning = rc != 0 and WARN_RC.get(name) == rc
262
+ steps.append((name, rc, warning))
263
+ print('ok ' + name if rc == 0 else f"{'WARN' if warning else 'FAIL'} {name} ({describe(rc, timeout)})",
264
+ file=out, flush=True)
265
+ try:
266
+ step('daemon-reload', [systemctl, '--user', 'daemon-reload'])
267
+ step('sync', [lanes, 'sync'])
268
+ step('restart lanes-statusd', [systemctl, '--user', 'restart', 'lanes-statusd.service'])
269
+ for unit in TRY_RESTART:
270
+ step('try-restart ' + unit[:-len('.service')], [systemctl, '--user', 'try-restart', unit])
271
+ for container in recorded_containers(state):
272
+ step('install-container ' + container, [lanes, 'install-container', container])
273
+ step('reload-views', [lanes, 'reload-views'])
274
+ step('ensure', [lanes, 'ensure', '--if-layout-changed'])
275
+ finished = True
276
+ finally:
277
+ ok = finished and not failed_steps(steps)
278
+ document = write_record(state, 'ok' if ok else 'partial', steps, tree, before, log, started, environ, home)
279
+ failed, warned = failed_steps(steps), warned_steps(steps)
280
+ was = f" (was {document['previous']})" if document['previous'] else ''
281
+ warnings = f"; {count(warned, 'warning')}: {', '.join(warned)}" if warned else ''
282
+ if ok and not warned:
283
+ print(f"ok: lanes {document['installed']} installed{was}; every step ran — {record_path(state)}", file=out)
284
+ elif ok:
285
+ print(f"ok: lanes {document['installed']} installed{was}; every step ran{warnings} — log {log}", file=out)
286
+ else:
287
+ print(f"partial: lanes {document['installed']} installed{was}; {count(failed, 'step')} failed: "
288
+ f"{', '.join(failed)}{warnings} — log {log}", file=out)
289
+ return document
290
+
291
+
292
+ def take_run_lock(descriptor, path, mode, pid=None):
293
+ """Take the run lock (an exclusive flock(2), without waiting) on `descriptor`, an open file that must be the
294
+ regular file `path` (never a link to another) -> None when taken, with this run written into it ({pid, mode,
295
+ started}; `pid` defaults to the parent's: host-install.sh's shell), else the one-line refusal naming the run that
296
+ holds it. The lock is the open file's: it lasts while any descriptor of it is open, the caller's included."""
297
+ try:
298
+ opened, named = os.fstat(descriptor), os.lstat(path)
299
+ except OSError as error:
300
+ return f'refused: cannot use the run lock {path}: {error.strerror}; nothing changed'
301
+ if not stat.S_ISREG(named.st_mode) or (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino):
302
+ return f'refused: the run lock {path} is not a regular file of its own; nothing changed'
303
+ try:
304
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
305
+ except BlockingIOError:
306
+ holder = L.read_json(path)
307
+ held = (f"another lanes {holder['mode']} is running (pid {holder['pid']}, since {holder['started']})"
308
+ if isinstance(holder, dict) and holder.get('mode') in MODES and isinstance(holder.get('pid'), int)
309
+ and UTC.match(str(holder.get('started', ''))) else 'another lanes install or update is running')
310
+ return (f'refused: {held}; it holds {path}. Nothing was changed or recorded — run this again once it has '
311
+ 'ended')
312
+ os.ftruncate(descriptor, 0)
313
+ os.pwrite(descriptor, (json.dumps({'pid': os.getppid() if pid is None else pid, 'mode': mode,
314
+ 'started': utc()}) + '\n').encode(), 0)
315
+ return None
316
+
317
+
318
+ # ------------------------------------------------------------------ lanes update (plan W9 U3, LNS-91)
319
+ PACKAGE = '@vibelord/lanes'
320
+ REGISTRY = 'https://registry.npmjs.org/@vibelord/lanes'
321
+ # The abbreviated document npm itself asks for (name, dist-tags, versions, modified): 4.8 KB where the full one is
322
+ # 20 KB (MEASURED 2026-10-07, @vibelord/lanes at 1.1.1, `{"latest": "1.1.1"}`).
323
+ ACCEPT = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*'
324
+ CURL_TIMEOUT = 20 # curl --max-time; run_bounded gives it 10 s more
325
+ CHANNELS = {'latest': 'latest', 'stable': 'lanes-stable'} # update.conf `channel=` -> the dist-tag it follows
326
+ CONF = Path('.config/lanes/update.conf') # under the home
327
+ # A check that answered less than this ago is not repeated (`--now` asks anyway). Below the timer's 30 min (plus up to
328
+ # 5 min of RandomizedDelaySec, and a re-run post-commit before the check), so a tick after a tick always asks; only a
329
+ # tick soon after a check by hand, or after a reboot, is skipped.
330
+ INTERVAL = 20 * 60
331
+ STALE = 2 * 60 * 60 # doctor: no check for this long means the timer does not run
332
+ NPX_TIMEOUT = 50 * 60 # the whole install through npx; lanes-update.service stops everything after 1 h
333
+ NPX_RECORD = 'update/npx' # the npx a `--server` install ran with (npm/lanes/bin/lanes-install.mjs recordNpx)
334
+ UPDATE_LOCK = 'locks/update.lock'
335
+ OUTCOMES = ('current', 'older', 'bad', 'unanswered', 'refused', 'installing', 'installed', 'failed')
336
+ OK, FAILED, USAGE = 0, 1, 2
337
+ UPDATE_USAGE = 'usage: lanes update [--now | --timer | --status | --rollback]'
338
+ INSTALLED_RC = {'ok': OK, 'partial': PARTIAL, 'refused': REFUSED, 'failed': FAILED} # the installer's result -> exit
339
+ UNIT_SETTING = re.compile(r'Environment="([A-Z][A-Z0-9_]*)=((?:[^"\\]|\\.)*)"\Z')
340
+ # The interrupts a rollback's flip and its record wait for (deferred_interrupts): Ctrl-C, `kill`/systemd's stop, and
341
+ # the hangup of a dropped SSH session.
342
+ INTERRUPTS = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
343
+ # The timer, as `lanes doctor` checks it (fix cycle 1 of W9 U3): `systemctl --user enable` links it from
344
+ # <unit folder>/timers.target.wants/ (its [Install] WantedBy=timers.target). Only the full server install enables it;
345
+ # an update (`--update-only`) enables nothing — on a server set up before 1.2.0 and since updated that way it is off.
346
+ TIMER = 'lanes-update.timer'
347
+ TIMER_WANTS = 'timers.target.wants'
348
+ ENABLE_TIMER = f'systemctl --user enable --now {TIMER}'
349
+
350
+
351
+ def parse_utc(text):
352
+ """Epoch seconds of a stamp utc() wrote, else None."""
353
+ if not isinstance(text, str) or not UTC.match(text):
354
+ return None
355
+ try:
356
+ return calendar.timegm(time.strptime(text, '%Y-%m-%dT%H:%M:%SZ'))
357
+ except ValueError:
358
+ return None
359
+
360
+
361
+ def ago(seconds):
362
+ seconds = max(0, int(seconds))
363
+ if seconds < 90:
364
+ return f'{seconds} s'
365
+ if seconds < 90 * 60:
366
+ return f'{seconds // 60} min'
367
+ if seconds < 36 * 3600:
368
+ return f'{seconds // 3600} h'
369
+ return f'{seconds // 86400} d'
370
+
371
+
372
+ def printable(text, limit=300):
373
+ """One line for a record, a journal and a terminal: each control character a blank, blank runs one blank."""
374
+ return ' '.join(re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(text)).split())[:limit]
375
+
376
+
377
+ def read_record(state):
378
+ document = L.read_json(record_path(state))
379
+ return document if isinstance(document, dict) else {}
380
+
381
+
382
+ def write_keys(state, **keys):
383
+ """Set record keys the installer does not own (`check`, `rerun`), keeping every other key -> the record. Only under
384
+ the run lock: the installer writes the same file under it. (A rollback's `bad` goes into the record of its flip:
385
+ write_record's `keys`.)"""
386
+ document = read_record(state)
387
+ document.update(keys)
388
+ L.atomic_write(record_path(state), json.dumps(document, indent=2) + '\n')
389
+ return document
390
+
391
+
392
+ def linked_tree(link):
393
+ """The existing tree `link` names (resolved), else None (no link, a dangling one, a loop)."""
394
+ try:
395
+ tree = Path(link).resolve(strict=True)
396
+ except (OSError, RuntimeError):
397
+ return None
398
+ return tree if tree.is_dir() else None
399
+
400
+
401
+ def previous_link(home):
402
+ return L.current_link(home).with_name('current.previous')
403
+
404
+
405
+ def read_conf(home):
406
+ """(channel, tag) from <home>/.config/lanes/update.conf: `channel=stable` follows the dist-tag `lanes-stable`,
407
+ `channel=latest` (or no file, no such line) `latest`; `#` comments and other keys are ignored. A channel that is
408
+ neither, or a file that cannot be read, is a ValueError: an update never guesses which releases were meant."""
409
+ path = Path(home) / CONF
410
+ try:
411
+ text = path.read_text()
412
+ except FileNotFoundError:
413
+ text = ''
414
+ except (OSError, ValueError) as error:
415
+ raise ValueError(f'{path} cannot be read ({getattr(error, "strerror", None) or error})') from None
416
+ channel = 'latest'
417
+ for line in text.splitlines():
418
+ name, equals, value = line.partition('=')
419
+ if equals and not line.lstrip().startswith('#') and name.strip() == 'channel':
420
+ channel = value.strip()
421
+ if channel not in CHANNELS:
422
+ raise ValueError(f'channel={printable(channel, 40)} in {path} is neither stable nor latest')
423
+ return channel, CHANNELS[channel]
424
+
425
+
426
+ def ask_registry(tag, run=None):
427
+ """The release the npm registry's dist-tag `tag` names -> (release, None), or (None, why): curl got no answer or an
428
+ HTTP error (-f), or the answer names no release under that tag."""
429
+ run = L.run_bounded if run is None else run
430
+ rc, out, err = run(['curl', '-fsS', '--max-time', str(CURL_TIMEOUT), '-H', f'Accept: {ACCEPT}', REGISTRY],
431
+ CURL_TIMEOUT + 10)
432
+ if rc != 0:
433
+ said = printable((err or '').strip().splitlines()[-1] if (err or '').strip() else '', 200)
434
+ return None, (f'no answer in {CURL_TIMEOUT + 10} s' if rc is None else
435
+ f'curl exit {rc}' + (f': {said}' if said else ''))
436
+ try:
437
+ document = json.loads(out)
438
+ except ValueError:
439
+ return None, f'{REGISTRY} answered no JSON'
440
+ tags = document.get('dist-tags') if isinstance(document, dict) else None
441
+ release = tags.get(tag) if isinstance(tags, dict) else None
442
+ if release is None:
443
+ return None, f'the registry names no {tag} release of {PACKAGE}'
444
+ if not isinstance(release, str) or len(release) > 32 or not RELEASE.match(release):
445
+ return None, f'the registry\'s {tag} is no release'
446
+ return release, None
447
+
448
+
449
+ def recorded_npx(state):
450
+ """The npx the npm wrapper recorded at the last `--server` install (`<state>/update/npx`, one absolute path) ->
451
+ (path, None), or (None, why) when there is none or it is gone (a Node.js moved by nvm: the plan's no self-heal)."""
452
+ path = Path(state) / NPX_RECORD
453
+ try:
454
+ lines = path.read_text().splitlines()
455
+ except FileNotFoundError:
456
+ return None, (f'no npx is recorded in {path} — the npm wrapper records the one a server install runs with: '
457
+ 'install once with npx @vibelord/lanes --server --update-only')
458
+ except (OSError, ValueError) as error:
459
+ return None, f'{path} cannot be read ({getattr(error, "strerror", None) or error})'
460
+ npx = lines[0] if len(lines) == 1 else ''
461
+ if not npx.startswith('/') or printable(npx, 4096) != npx:
462
+ return None, f'{path} names no absolute path'
463
+ if not (os.path.isfile(npx) and os.access(npx, os.X_OK)):
464
+ return None, (f'the recorded npx {npx} is gone or not executable (Node.js moved or upgraded?) — install once '
465
+ 'with npx @vibelord/lanes --server --update-only from a shell whose npx works')
466
+ return npx, None
467
+
468
+
469
+ def services_module(repo=None):
470
+ """install/services.py of `repo` (default this tree) as a module: the units' settings and, for a rollback, the
471
+ install of another tree (`register(..., install=True)`)."""
472
+ spec = importlib.util.spec_from_file_location('lanes_update_services',
473
+ Path(repo if repo is not None else L.REPO) / 'install/services.py')
474
+ module = importlib.util.module_from_spec(spec)
475
+ spec.loader.exec_module(module)
476
+ return module
477
+
478
+
479
+ def carried_names(services):
480
+ """The settings an install renders into the units from ITS environment (services.py), which a timer's environment
481
+ or another shell does not carry."""
482
+ return ('LANES_WORKSPACES',) + tuple(services.ECONOMY_SETTINGS) + tuple(services.NOTIFY_SETTINGS)
483
+
484
+
485
+ def carried_settings(units_dir, names, environ):
486
+ """{name: value} for each of `names` an installed lanes unit in `units_dir` sets (`Environment="NAME=VALUE"`,
487
+ unescaped as services.unit_value escaped it) and `environ` does not: an update installs with the settings the
488
+ running units have, never without them (the economy poller's control URL, the notifier's config path)."""
489
+ found = {}
490
+ for unit in sorted(Path(units_dir).glob('lanes*.service')):
491
+ try:
492
+ lines = unit.read_text().splitlines()
493
+ except (OSError, ValueError):
494
+ continue
495
+ for line in lines:
496
+ match = UNIT_SETTING.match(line)
497
+ if match and match.group(1) in names and match.group(1) not in found:
498
+ found[match.group(1)] = re.sub(r'\\(.)', r'\1', match.group(2).replace('%%', '%'))
499
+ return {name: value for name, value in found.items() if not environ.get(name)}
500
+
501
+
502
+ def npx_environment(npx, environ, carried, latest, checked_at):
503
+ """The environment of the npx an update runs: this one, with the settings carried from the units, npx's folder first
504
+ on PATH (npx is `#!/usr/bin/env node`, and node lives beside it — a unit's PATH has neither), the check handed to
505
+ the installer's record (LANES_UPDATE_LATEST, LANES_UPDATE_CHECKED_AT), and without the options of an npx that
506
+ started this process (`npx -c` / `-p` export npm_config_call / npm_config_package, which a nested npx obeys)."""
507
+ env = {name: value for name, value in environ.items()
508
+ if not re.fullmatch(r'npm_config_(call|package)', name, re.IGNORECASE)}
509
+ env.update(carried)
510
+ env['PATH'] = os.pathsep.join([str(Path(npx).parent), environ.get('PATH') or '/usr/local/bin:/usr/bin:/bin'])
511
+ env['LANES_UPDATE_LATEST'] = latest
512
+ env['LANES_UPDATE_CHECKED_AT'] = checked_at
513
+ return env
514
+
515
+
516
+ def run_npx(argv, env, output=None, timeout=NPX_TIMEOUT):
517
+ """Run the install through npx in its own session -> its exit status (128+N for signal N, 124 when it did not end
518
+ in `timeout` s, 127 when it could not start). Its output goes to this process's (`output`: a file instead). An
519
+ interrupt here (Ctrl-C, SIGTERM) stops its whole process group first, so the installer records it."""
520
+ try:
521
+ child = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=output, stderr=output, env=env,
522
+ start_new_session=True)
523
+ except OSError:
524
+ return 127
525
+ try:
526
+ rc = child.wait(timeout)
527
+ except subprocess.TimeoutExpired:
528
+ stop(child)
529
+ return 124
530
+ except BaseException:
531
+ stop(child)
532
+ raise
533
+ return 128 - rc if rc < 0 else rc
534
+
535
+
536
+ @contextlib.contextmanager
537
+ def update_lock(state):
538
+ """One `lanes update` at a time: an exclusive flock(2), without waiting, on `<state>/locks/update.lock`, held to the
539
+ end of the run -> yields None when taken (this pid written into it), else the one line naming the run that holds
540
+ it. Distinct from the run lock: that one is released while npx runs the installer, which takes it."""
541
+ path = Path(state) / UPDATE_LOCK
542
+ path.parent.mkdir(parents=True, exist_ok=True)
543
+ descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
544
+ try:
545
+ try:
546
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
547
+ held = None
548
+ except BlockingIOError:
549
+ holder = L.read_json(path)
550
+ held = ('another lanes update is running'
551
+ + (f" (pid {holder['pid']}, since {holder['started']})"
552
+ if isinstance(holder, dict) and isinstance(holder.get('pid'), int)
553
+ and UTC.match(str(holder.get('started', ''))) else '')
554
+ + f'; it holds {path}')
555
+ if held is None:
556
+ os.ftruncate(descriptor, 0)
557
+ os.pwrite(descriptor, (json.dumps({'pid': os.getpid(), 'started': utc()}) + '\n').encode(), 0)
558
+ yield held
559
+ finally:
560
+ os.close(descriptor)
561
+
562
+
563
+ @contextlib.contextmanager
564
+ def run_lock(state):
565
+ """The run lock (`<state>/update/.lock`), as host-install.sh takes it, for the parts of `lanes update` that write the
566
+ record or run a post-commit themselves -> yields None when taken, else take_run_lock's refusal."""
567
+ path = lock_path(state)
568
+ path.parent.mkdir(parents=True, exist_ok=True)
569
+ descriptor = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_CLOEXEC, 0o600) # as `exec 9>>`
570
+ try:
571
+ yield take_run_lock(descriptor, path, 'update', pid=os.getpid())
572
+ finally:
573
+ os.close(descriptor)
574
+
575
+
576
+ @contextlib.contextmanager
577
+ def recorded_interrupts():
578
+ """SIGTERM and SIGHUP raise SystemExit while a post-commit or npx runs here, so post_commit records the step they
579
+ stopped and run_npx stops the installer's group (as main() does for `update.py post-commit`)."""
580
+ saved = {signum: signal.signal(signum, raise_exit) for signum in (signal.SIGTERM, signal.SIGHUP)}
581
+ try:
582
+ yield
583
+ finally:
584
+ for signum, handler in saved.items():
585
+ signal.signal(signum, handler)
586
+
587
+
588
+ @contextlib.contextmanager
589
+ def deferred_interrupts():
590
+ """SIGINT, SIGTERM and SIGHUP wait while the body runs: one that arrives is noted, and once the body has ended the
591
+ handlers it found are put back and the first one noted is raised again (signal.raise_signal: KeyboardInterrupt for
592
+ SIGINT, recorded_interrupts' SystemExit for the others, the default action where nothing handles it). A rollback's
593
+ flip and the record that says so are one step this way: no interrupt lands between `current` moving and the
594
+ release left being marked `bad` (fix cycle 1 of W9 U3). The wait is that of install/services.py's register and the
595
+ record's write (milliseconds; at most its 60 s install-lock wait and systemctl's 10 s). Python handlers, never the
596
+ signal mask: a child the body starts gets the default handlers when it execs, not a blocked signal."""
597
+ noted = []
598
+ saved = {signum: signal.signal(signum, lambda signum, _frame: noted.append(signum)) for signum in INTERRUPTS}
599
+ try:
600
+ yield
601
+ finally:
602
+ for signum, handler in saved.items():
603
+ signal.signal(signum, handler)
604
+ if noted:
605
+ signal.raise_signal(noted[0])
606
+
607
+
608
+ class Update:
609
+ """One `lanes update` run (update_main). `key` is bin/lanes version_key: the one comparison of releases."""
610
+
611
+ def __init__(self, key, timer=False, out=None, environ=None, home=None, clock=None, output=None):
612
+ self.key, self.timer = key, timer
613
+ self.out = sys.stdout if out is None else out
614
+ self.environ = os.environ if environ is None else environ
615
+ self.home = Path.home() if home is None else Path(home)
616
+ self.clock = time.time if clock is None else clock
617
+ self.output = output # where npx writes (None: this process's stdout and stderr)
618
+ self.state = L.STATE
619
+ self.log = record_path(self.state).with_name('last.log')
620
+
621
+ def say(self, text):
622
+ print(f'lanes update: {text}', file=self.out, flush=True)
623
+
624
+ def refused(self, text):
625
+ """A run that does nothing: exit 3 by hand; 0 under the timer, whose unit then fails only when an update did."""
626
+ self.say(text)
627
+ return OK if self.timer else REFUSED
628
+
629
+ def run(self, flag):
630
+ with update_lock(self.state) as held:
631
+ if held:
632
+ return self.refused(f'{held}; this run does nothing')
633
+ tree = installed_tree(self.home)
634
+ if tree is None:
635
+ return self.refused(f'no release is installed here ({L.current_link(self.home)} names no tree): '
636
+ 'install the server first — npx @vibelord/lanes --server')
637
+ if not L.staged_tree(tree):
638
+ return self.refused(f'this server runs the checkout {tree}, not an npm release: update it with git '
639
+ 'pull, then ./install.sh --server --update-only')
640
+ if L.tree_version(tree) is None or self.key(L.tree_version(tree)) is None:
641
+ return self.refused(f'the installed tree {tree} names no release in its VERSION: re-run the server '
642
+ 'installer')
643
+ with recorded_interrupts():
644
+ return self.rollback(tree) if flag == '--rollback' else self.update(tree, force=flag == '--now')
645
+
646
+ def fresh_log(self, header):
647
+ self.log.parent.mkdir(parents=True, exist_ok=True)
648
+ self.log.write_text(f'== {header}\n')
649
+
650
+ @staticmethod
651
+ def kept(record):
652
+ """The record's own check (top-level `latest`, `checked_at`), handed to write_record unchanged by a run that
653
+ did not ask the registry: a re-run, a rollback."""
654
+ return {'LANES_UPDATE_LATEST': str(record.get('latest') or ''),
655
+ 'LANES_UPDATE_CHECKED_AT': str(record.get('checked_at') or '')}
656
+
657
+ def rerun(self, tree, record):
658
+ """A last run that ended `partial`: the post-commit of the tree `current` names, again -> OK or PARTIAL."""
659
+ installed = L.tree_version(tree)
660
+ self.say(f'the last update of {installed} ended partial: its post-commit again, from {tree} (log {self.log})')
661
+ self.fresh_log(f'lanes update: the post-commit of {tree} again (the last run ended partial)')
662
+ document = post_commit(tree, self.state, self.log, (), linked_tree(previous_link(self.home)),
663
+ utc(self.clock()), out=self.out, environ=self.kept(record), home=self.home)
664
+ write_keys(self.state, rerun=installed)
665
+ return OK if document['result'] == 'ok' else PARTIAL
666
+
667
+ def update(self, tree, force):
668
+ installed, code = L.tree_version(tree), OK
669
+ with run_lock(self.state) as refusal:
670
+ if refusal:
671
+ return self.refused(refusal)
672
+ record = read_record(self.state)
673
+ if record.get('result') == 'partial':
674
+ if self.timer and record.get('rerun') == installed:
675
+ # Once per release from the timer: a post-commit that fails the same way every 30 min would
676
+ # restart every service each time. The run still exits 4 (the unit fails) until a person acts.
677
+ self.say(f'the last update of {installed} ended partial again after its post-commit was run '
678
+ f'once more: lanes update (by hand) runs it again — log {self.log}')
679
+ code = PARTIAL
680
+ else:
681
+ code = self.rerun(tree, record)
682
+ record = read_record(self.state)
683
+ last = record.get('check') if isinstance(record.get('check'), dict) else {}
684
+ asked, now = parse_utc(last.get('at')), self.clock()
685
+ if not force and last.get('latest') and asked is not None and 0 <= now - asked < INTERVAL:
686
+ self.say(f"checked {ago(now - asked)} ago ({last.get('tag')} names {last.get('latest')}): the next "
687
+ f'check is due {ago(asked + INTERVAL - now)} from now — lanes update --now asks now')
688
+ return code
689
+ at = utc(now)
690
+ check = {'at': at, 'by': 'timer' if self.timer else 'hand', 'channel': None, 'tag': None,
691
+ 'latest': None, 'installed': installed}
692
+ try:
693
+ check['channel'], check['tag'] = read_conf(self.home)
694
+ except ValueError as error:
695
+ return self.finish(check, 'refused', f'{error}: nothing was asked or installed',
696
+ code or (OK if self.timer else REFUSED))
697
+ tag = check['tag']
698
+ latest, why = ask_registry(tag)
699
+ if latest is None or self.key(latest) is None:
700
+ return self.finish(check, 'unanswered', f'{why or f"{tag} names {latest}, no release"}; nothing '
701
+ 'changed', code or FAILED)
702
+ check['latest'] = latest
703
+ new, old = self.key(latest), self.key(installed)
704
+ if new == old:
705
+ return self.finish(check, 'current', f'{tag} names {latest}: installed', code)
706
+ if new < old:
707
+ return self.finish(check, 'older', f'{tag} names {latest}, older than the installed {installed}: '
708
+ 'not installed (an update never goes back; lanes update --rollback does)', code)
709
+ if latest == record.get('bad'):
710
+ return self.finish(check, 'bad', f'{tag} names {latest}, the release a rollback left (bad): not '
711
+ 'installed until a newer one is published', code)
712
+ npx, why = recorded_npx(self.state)
713
+ if npx is None:
714
+ return self.finish(check, 'failed', f'{tag} names {latest}, not installed: {why}', code or FAILED)
715
+ argv = [npx, '--yes', f'{PACKAGE}@{latest}', '--server', '--update-only']
716
+ self.finish(check, 'installing', f'{installed} -> {latest} ({tag}): {shlex.join(argv)}', code)
717
+ return self.install(check, argv)
718
+
719
+ def finish(self, check, outcome, said, code):
720
+ """Record the check (`check` in the record, under the run lock) and say it -> code."""
721
+ check.update(outcome=outcome, said=printable(said))
722
+ write_keys(self.state, check=check)
723
+ self.say(check['said'])
724
+ return code
725
+
726
+ def install(self, check, argv):
727
+ """npx runs the installer (`--server --update-only`) with the run lock released -> the installer's result as an
728
+ exit status (FAILED when it recorded no run: npx or the wrapper failed before it)."""
729
+ services = services_module()
730
+ carried = carried_settings(services.manager_unit_dir(self.home), carried_names(services), self.environ)
731
+ if carried:
732
+ self.say(f"with {', '.join(sorted(carried))} from the installed units")
733
+ rc = run_npx(argv, npx_environment(argv[0], self.environ, carried, check['latest'], check['at']), self.output)
734
+ with run_lock(self.state) as refusal:
735
+ record = read_record(self.state)
736
+ recorded = record.get('checked_at') == check['at'] and record.get('latest') == check['latest']
737
+ result = record.get('result') if recorded and record.get('result') in INSTALLED_RC else None
738
+ if result:
739
+ outcome, said = 'installed', f"npx exit {rc}: the update to {check['latest']} ended {result}"
740
+ else:
741
+ outcome, said = 'failed', (f"npx exit {rc} before the installer recorded a run: {check['latest']} is "
742
+ 'not installed (npx\'s own output says why: the terminal, or journalctl '
743
+ '--user -u lanes-update.service)')
744
+ check.update(outcome=outcome, said=said, exit=rc)
745
+ if refusal:
746
+ self.say(f'{said} (not recorded: {refusal})')
747
+ else:
748
+ write_keys(self.state, check=check)
749
+ self.say(said)
750
+ code = INSTALLED_RC[result] if result else FAILED
751
+ return OK if self.timer and code == REFUSED else code
752
+
753
+ def rollback(self, tree):
754
+ """`current` back to the tree `current.previous` names, the release left marked `bad`, then its post-commit.
755
+ The flip and its record are one step no interrupt splits (deferred_interrupts): once `current` names the tree
756
+ rolled back to, one write records `partial` (the post-commit has not run yet, so the next `lanes update` runs
757
+ it) with the release left as `bad`, before the post-commit starts. A Ctrl-C, or the SIGHUP of a dropped SSH
758
+ session, during the post-commit or the flip therefore never leaves that release unmarked for the next timer
759
+ tick to install again (fix cycle 1 of W9 U3: the mark used to be written after the post-commit)."""
760
+ with run_lock(self.state) as refusal:
761
+ if refusal:
762
+ return self.refused(refusal)
763
+ link, bad = previous_link(self.home), L.tree_version(tree)
764
+ previous = linked_tree(link)
765
+ if previous is None or previous == tree:
766
+ return self.refused(f'nothing to roll back to: {link} names ' + (
767
+ 'no tree' if previous is None else 'the installed tree') + ' (the install that last moved '
768
+ '`current` records the release before it there)')
769
+ target, record = L.tree_version(previous), read_record(self.state)
770
+ if record.get('bad') is not None and target == record.get('bad'):
771
+ return self.refused(f'{link} names {target}, the release the last rollback left (bad): nothing to roll '
772
+ f'back to — to install it again: npx @vibelord/lanes@{target} --server --update-only')
773
+ started, kept = utc(self.clock()), self.kept(record)
774
+ self.say(f'{bad} -> {target}: installing {previous} again (log {self.log})')
775
+ self.fresh_log(f'lanes update --rollback: {tree} -> {previous}')
776
+ with deferred_interrupts():
777
+ rc = self.flip(previous)
778
+ if installed_tree(self.home) != previous:
779
+ result = 'refused' if rc == REFUSED else 'failed'
780
+ write_record(self.state, result, [('rollback', rc)], previous, tree, self.log, started, kept,
781
+ self.home)
782
+ print(f'FAIL rollback (exit {rc})', file=self.out)
783
+ self.say(f'{result}: nothing changed; {bad} still runs — log {self.log}')
784
+ return REFUSED if result == 'refused' else FAILED
785
+ # The commit point of the rollback, recorded in one write: `partial` until the post-commit has run,
786
+ # the release left `bad`, and no `rerun` — that mark belongs to an earlier post-commit of this release,
787
+ # and kept it would stop the timer re-running the one this rollback starts, should it be cut short.
788
+ write_record(self.state, 'partial', [('rollback', rc)], previous, tree, self.log, started, kept,
789
+ self.home, keys={'bad': bad, 'rerun': None})
790
+ print('ok rollback' if rc == OK else f'FAIL rollback (exit {rc})', file=self.out, flush=True)
791
+ self.say(f'{bad} is marked bad: no update installs it again; a newer release is installed as usual')
792
+ document = post_commit(previous, self.state, self.log, [('rollback', rc)], tree, started, out=self.out,
793
+ environ=kept, home=self.home)
794
+ return OK if document['result'] == 'ok' else PARTIAL
795
+
796
+ def flip(self, previous):
797
+ """Install `previous` again as the server installer's commit does — every unit rendered from ITS templates,
798
+ `current.previous` then the tree being left, then `current` — with this tree's install/services.py (no network,
799
+ no npx: a rollback works while npm does not) and the settings the installed units carry -> OK, REFUSED (a
800
+ services.Refusal: nothing written) or FAILED (anything else; the traceback is in the log)."""
801
+ services = services_module()
802
+ with open(self.log, 'a') as log, contextlib.redirect_stderr(log):
803
+ log.write(f'== install/services.py of {L.REPO}: install {previous}\n')
804
+ log.flush()
805
+ try:
806
+ units = services.manager_unit_dir(self.home)
807
+ env = dict(self.environ, **carried_settings(units, carried_names(services), self.environ))
808
+ services.register(previous, self.home, L.SHARE, self.state, env.get('LANES_WORKSPACES'),
809
+ services.economy_settings(env), services.notify_settings(env), units_dir=units,
810
+ install=True)
811
+ except services.Refusal as error:
812
+ log.write(f'services.py: refused, nothing was changed: {error}\n')
813
+ return REFUSED
814
+ except Exception: # noqa: BLE001 — every other failure is the log's to say
815
+ log.write(traceback.format_exc())
816
+ return FAILED
817
+ return OK
818
+
819
+
820
+ def show_status(state, out):
821
+ """`lanes update --status`: the record as it is on disk -> 0, or 1 when there is none yet."""
822
+ path = record_path(state)
823
+ try:
824
+ text = path.read_text()
825
+ except FileNotFoundError:
826
+ print(f'lanes update: no update is recorded yet ({path})', file=out)
827
+ return FAILED
828
+ except (OSError, ValueError) as error:
829
+ print(f'lanes update: {path} cannot be read ({getattr(error, "strerror", None) or error})', file=out)
830
+ return FAILED
831
+ out.write(text if text.endswith('\n') else text + '\n')
832
+ return OK
833
+
834
+
835
+ def update_main(args, key, out=None, environ=None, home=None, clock=None, output=None):
836
+ """`lanes update [--now | --timer | --status | --rollback]` (bin/lanes cmd_update) -> the exit status: 0 nothing to
837
+ do, or done and ok (under --timer also a run that did nothing: another update running, a checkout, no installed
838
+ release, a bad update.conf); 4 partial; 3 refused (by hand); 1 failed (the registry gave no release, npx could not
839
+ install, the install failed); 2 usage."""
840
+ out = sys.stdout if out is None else out
841
+ if len(args) > 1 or (args and args[0] not in ('--now', '--timer', '--status', '--rollback')):
842
+ print(UPDATE_USAGE, file=out)
843
+ return USAGE
844
+ flag = args[0] if args else None
845
+ if flag == '--status':
846
+ return show_status(L.STATE, out)
847
+ return Update(key, timer=flag == '--timer', out=out, environ=environ, home=home, clock=clock,
848
+ output=output).run(flag)
849
+
850
+
851
+ def timer_enabled(units):
852
+ """Whether lanes-update.timer is enabled in one of the unit folders `units`: `<folder>/timers.target.wants/
853
+ lanes-update.timer`, the link `systemctl --user enable` makes, names an existing unit. A file read, as every other
854
+ doctor line is (doctor never asks systemctl)."""
855
+ return any((Path(folder) / TIMER_WANTS / TIMER).exists() for folder in units)
856
+
857
+
858
+ def doctor_line(state=None, home=None, now=None, units=None):
859
+ """`lanes doctor`'s update line -> (mark, text), mark ✓ | ✗ | ⚠ | '' (information), or None without an installed
860
+ release (doctor's `current` line says that already). From the record — the last check and its outcome, the last
861
+ install's result, a rollback's `bad` — and from whether lanes-update.timer is enabled in one of the unit folders
862
+ `units` (bin/lanes unit_dirs; default ~/.config/systemd/user): ⚠ at once when it is not (fix cycle 1 of W9 U3), not
863
+ only once no check has run for STALE."""
864
+ state = L.STATE if state is None else state
865
+ home = Path.home() if home is None else Path(home)
866
+ now = time.time() if now is None else now
867
+ units = [home / '.config/systemd/user'] if units is None else units
868
+ tree = installed_tree(home)
869
+ if tree is None:
870
+ return None
871
+ if not L.staged_tree(tree):
872
+ return ('', 'updates: this server runs a checkout, which lanes update and its timer leave alone: git pull, then '
873
+ './install.sh --server --update-only')
874
+ record = read_record(state)
875
+ enable = ENABLE_TIMER
876
+ off = (f'{TIMER} is not enabled, so no newer release installs itself: {enable} (the full server install enables '
877
+ 'it; an update with --update-only never does)')
878
+ timer = timer_enabled(units)
879
+ if not record:
880
+ if not timer:
881
+ return ('⚠', f'updates: none recorded yet — {off}; lanes update --now checks now')
882
+ return ('⚠', f'updates: none recorded yet — lanes-update.timer checks 15 min after a boot, then every 30 min '
883
+ f'({enable}); lanes update --now checks now')
884
+ check = record.get('check') if isinstance(record.get('check'), dict) else {}
885
+ asked = parse_utc(check.get('at')) or parse_utc(record.get('checked_at'))
886
+ mark, parts = '✓', []
887
+ if check:
888
+ parts.append(f"checked {ago(now - asked) + ' ago' if asked else 'at an unknown time'}: "
889
+ f"{printable(check.get('said') or check.get('outcome') or '?', 200)}")
890
+ if check.get('outcome') == 'failed':
891
+ mark = '✗'
892
+ elif check.get('outcome') in ('unanswered', 'refused', 'installing'):
893
+ mark = '⚠'
894
+ result = record.get('result')
895
+ if result in INSTALLED_RC:
896
+ steps = [entry.get('name') for entry in record.get('steps') or ()
897
+ if isinstance(entry, dict) and entry.get('rc') and not entry.get('warning')]
898
+ parts.append(f"last update {result}: {printable(record.get('previous') or '—', 40)} → "
899
+ f"{printable(record.get('installed') or '—', 40)}"
900
+ + (f" ({', '.join(printable(name, 60) for name in steps[:4])} failed)" if steps else '')
901
+ + ('' if result == 'ok' else f" — log {printable(record.get('log') or '?', 200)}")
902
+ + (' (lanes update runs its post-commit again)' if result == 'partial' else ''))
903
+ mark = '✗' if result in ('partial', 'failed') else '⚠' if result == 'refused' and mark == '✓' else mark
904
+ if record.get('bad'):
905
+ parts.append(f"{printable(record['bad'], 40)} marked bad by a rollback")
906
+ if not timer:
907
+ parts.append(off)
908
+ mark = '⚠' if mark == '✓' else mark
909
+ elif asked is None or now - asked > STALE:
910
+ parts.append((f'no check for {ago(now - asked)}' if asked else 'no check recorded')
911
+ + f': is the timer on? {enable}')
912
+ mark = '⚠' if mark == '✓' else mark
913
+ return (mark, 'updates: ' + ' · '.join(parts))
914
+
915
+
916
+ def step_argument(text):
917
+ match = STEP.match(text)
918
+ if not match or int(match.group(2)) > 255:
919
+ raise argparse.ArgumentTypeError(f'not NAME=RC: {text!r}')
920
+ return match.group(1), int(match.group(2))
921
+
922
+
923
+ def parser():
924
+ top = argparse.ArgumentParser(prog='update.py', description='a server update after its commit point (W9 U2)')
925
+ commands = top.add_subparsers(dest='command', required=True)
926
+ for name in ('post-commit', 'record'):
927
+ command = commands.add_parser(name)
928
+ command.add_argument('--state', required=True)
929
+ command.add_argument('--tree', required=True)
930
+ command.add_argument('--log', required=True)
931
+ command.add_argument('--before', default='')
932
+ command.add_argument('--started', default='')
933
+ command.add_argument('--step', action='append', default=[], type=step_argument)
934
+ if name == 'record':
935
+ command.add_argument('--result', required=True, choices=('refused', 'failed', 'partial'))
936
+ lock = commands.add_parser('lock')
937
+ lock.add_argument('--state', required=True)
938
+ lock.add_argument('--fd', required=True, type=descriptor_argument)
939
+ lock.add_argument('--mode', required=True, choices=MODES)
940
+ return top
941
+
942
+
943
+ def descriptor_argument(text):
944
+ if not text.isdigit() or not 3 <= int(text) <= 9:
945
+ raise argparse.ArgumentTypeError(f'not a descriptor 3-9: {text!r}')
946
+ return int(text)
947
+
948
+
949
+ def raise_exit(signum, _frame):
950
+ raise SystemExit(128 + signum)
951
+
952
+
953
+ def main(argv=None):
954
+ args = parser().parse_args(argv)
955
+ if args.command == 'lock':
956
+ refusal = take_run_lock(args.fd, lock_path(args.state), args.mode)
957
+ if refusal:
958
+ print(refusal, flush=True)
959
+ return REFUSED
960
+ return 0
961
+ before = Path(args.before) if args.before else None
962
+ if args.command == 'record':
963
+ write_record(args.state, args.result, args.step, Path(args.tree), before, args.log, args.started or None)
964
+ return 0
965
+ for signum in (signal.SIGTERM, signal.SIGHUP): # a stopped update still writes its record (post_commit finally)
966
+ signal.signal(signum, raise_exit)
967
+ document = post_commit(Path(args.tree), args.state, args.log, args.step, before, args.started or None)
968
+ return 0 if document['result'] == 'ok' else PARTIAL
969
+
970
+
971
+ if __name__ == '__main__':
972
+ sys.exit(main())