@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.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +45 -22
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2459 -164
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +106 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1091 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1140 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +618 -13
- package/payload/lanes/docs/DESIGN.md +122 -44
- package/payload/lanes/docs/INSTALL.md +157 -17
- package/payload/lanes/docs/RUNBOOK.md +603 -72
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +777 -136
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +163 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/members.py +16 -2
- package/payload/lanes/libexec/notify.py +104 -24
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2135 -361
- package/payload/lanes/libexec/statusd.py +137 -10
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +121 -30
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +972 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/README.md +83 -24
- package/payload/lanes/web/browser.js +3 -5
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +44 -32
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- 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())
|