@vibelord/lanes 1.0.2 → 1.0.3
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/package.json +1 -1
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +21 -8
- package/payload/lanes/container/env.sh +1 -1
- package/payload/lanes/container/lane +5 -5
- package/payload/lanes/container/lane-signal +4 -4
- package/payload/lanes/docs/ARCHITECTURE.md +14 -7
- package/payload/lanes/docs/DESIGN.md +3 -3
- package/payload/lanes/docs/INSTALL.md +37 -2
- package/payload/lanes/docs/RUNBOOK.md +52 -23
- package/payload/lanes/docs/SHARING.md +239 -4
- package/payload/lanes/holders.lock +10 -0
- package/payload/lanes/install/enable-studio-host.sh +14 -0
- package/payload/lanes/install/host-install.sh +5 -2
- package/payload/lanes/install/services.py +101 -4
- package/payload/lanes/install.sh +1 -1
- package/payload/lanes/libexec/agent_state.py +1 -1
- package/payload/lanes/libexec/cursor_sync.py +2 -2
- package/payload/lanes/libexec/economy_poll.py +3 -3
- package/payload/lanes/libexec/lanes_lib.py +1 -1
- package/payload/lanes/libexec/notify.py +3 -3
- package/payload/lanes/libexec/registrations.py +1 -1
- package/payload/lanes/libexec/session_actions.py +1 -1
- package/payload/lanes/libexec/sidebar.py +7 -7
- package/payload/lanes/libexec/statusd.py +25 -10
- package/payload/lanes/libexec/studio_host.py +1608 -0
- package/payload/lanes/systemd/lanes-economy.service +1 -1
- package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
- package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
- package/payload/lanes/systemd/lanes-studio-host.service +37 -0
- package/payload/lanes/web/README.md +2 -2
- package/payload/lanes/zellij/config.kdl +1 -1
- package/payload/manifest.json +4 -3
|
@@ -0,0 +1,1608 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The Throttle Studio Host co-located on this lanes server (docs/SHARING.md "Co-located Studio Host";
|
|
3
|
+
docs/design/throttle-studio.md §8.10 C3). Python 3 stdlib only.
|
|
4
|
+
|
|
5
|
+
lanes studio-host status [--json] [--check]
|
|
6
|
+
what is installed, enrolled and running; exit 0 only when the Host runs enrolled (and, with --check, its
|
|
7
|
+
own `studio host --check` exits 0)
|
|
8
|
+
lanes studio-host re-key [--yes] [--dry-run]
|
|
9
|
+
after a revoke: stop the Host, remove the machine key, its dead enrolment and every record of it, and publish
|
|
10
|
+
a NEW public key for the next deploy (or a registration key) to enrol. Nothing changes without --yes.
|
|
11
|
+
lanes studio-host install [--from PATH [--sha256 HEX]]
|
|
12
|
+
select the throttle-rc binary the unit runs: the holders.lock opt-in pin (download, both digests checked),
|
|
13
|
+
or a locally built file (hashed, copied, re-hashed). Never starts or restarts anything.
|
|
14
|
+
sh install/enable-studio-host.sh [options] (→ `enable`; --help lists the options)
|
|
15
|
+
the opt-in step: config in lanes-server mode, the machine's PUBLIC key for the deploy, enrolment from a
|
|
16
|
+
deploy receipt or a registration key, the units — or the unit installed WAITING for enrolment.
|
|
17
|
+
lanes-studio-host-enrol.service runs `enable --retry`: acts only on something new, never loops on a refusal.
|
|
18
|
+
|
|
19
|
+
Invariants (design §8.10 C5, each pinned by tests/unit/test_studio_host.py):
|
|
20
|
+
* the machine key is created ON THIS MACHINE by `throttle-rc studio publish-key`; this module never reads it;
|
|
21
|
+
* no bearer secret is created or stored: the receipt is inert, the registration key is read by throttle-rc
|
|
22
|
+
from its 0600 file and never by this module — not opened, not hashed: its stat identity and access(2) only
|
|
23
|
+
(review round 5);
|
|
24
|
+
* one Host per machine: refused while `throttle-rc studio service`'s own unit is enabled or active, and the
|
|
25
|
+
hand-built `lanes-sharing-host.service` is only replaced with --migrate-hand-built, after its text is saved;
|
|
26
|
+
* throttle-rc children get an allowlisted environment, nothing else from the caller.
|
|
27
|
+
"""
|
|
28
|
+
import base64
|
|
29
|
+
import errno
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import tarfile
|
|
38
|
+
import tempfile
|
|
39
|
+
import time
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
UNIT = 'lanes-studio-host.service'
|
|
43
|
+
ENROL_SERVICE = 'lanes-studio-host-enrol.service'
|
|
44
|
+
ENROL_TIMER = 'lanes-studio-host-enrol.timer'
|
|
45
|
+
SUPERVISOR_UNIT = 'throttle-rc-studio-host.service' # `throttle-rc studio service install`'s own unit (design §8.9)
|
|
46
|
+
HAND_BUILT_UNIT = 'lanes-sharing-host.service' # ceobox's hand-built sharing Host (docs/handoffs/2026-09-22)
|
|
47
|
+
BINARY = 'throttle-rc'
|
|
48
|
+
PIN_RECORD = 'throttle-rc.pin.json'
|
|
49
|
+
LOCK_OPT_IN = 'opt-in'
|
|
50
|
+
CONFIG_HEADER = '# Written by lanes/install/enable-studio-host.sh'
|
|
51
|
+
HEX64 = re.compile(r'^[0-9a-f]{64}$')
|
|
52
|
+
VERSION_WORD = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
|
53
|
+
MACHINE_ID = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
|
|
54
|
+
# Only these reach a throttle-rc child (C5): directories and locale, never a token or a key from the caller.
|
|
55
|
+
ENV_ALLOWLIST = ('HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LC_ALL', 'TMPDIR', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME',
|
|
56
|
+
'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'LANES_STATE', 'LANES_SHARE', 'LANES_WORKSPACES')
|
|
57
|
+
SYSTEMCTL_TIMEOUT = 30
|
|
58
|
+
RETRY_KEY_AFTER = 3600
|
|
59
|
+
RECEIPT_REFUSAL_MEMORY = ('receipt_refused_sha', 'receipt_refused_config_sha', 'receipt_refused_at',
|
|
60
|
+
'receipt_refused_code')
|
|
61
|
+
# Review round 6: what `studio enroll` answered for the key the timer remembers trying (exit and code), so `status`
|
|
62
|
+
# can say the key was refused instead of pointing at the deploy for the hour the timer skips it.
|
|
63
|
+
KEY_REFUSAL_MEMORY = ('key_failed_exit', 'key_failed_code')
|
|
64
|
+
# The two provenance marks throttle-rc writes into a refused state.json's detail (apps/throttle-rc/src/studio_host/
|
|
65
|
+
# mod.rs PLATFORM_REFUSAL_MARK / SELF_REVOKE_MARK; pinned by a test that reads that file). A revocation is MEASURED
|
|
66
|
+
# only with one of them, for this config's server — the rule `measured_revocation` applies.
|
|
67
|
+
PLATFORM_REFUSAL_MARK = '[platform refused this dial]'
|
|
68
|
+
SELF_REVOKE_MARK = '[measured by `throttle-rc studio revoke` on this machine]'
|
|
69
|
+
# Review round 3: a non-default --receipt / --registration-key-file is REMEMBERED in the enrolment record, so the
|
|
70
|
+
# timer (which runs `--retry` with no options) and `status` watch the path the operator named.
|
|
71
|
+
REMEMBERED_PATHS = {'receipt': 'receipt_path', 'key_file': 'registration_key_path'}
|
|
72
|
+
# The timer's memory of a tried registration key (review round 5): its stat identity, never a digest of its bytes.
|
|
73
|
+
# `key_tried_sha` is the round-1..4 spelling (a SHA-256 of the secret); it is dropped wherever the record is saved.
|
|
74
|
+
LEGACY_KEY_DIGEST = 'key_tried_sha'
|
|
75
|
+
# The lines whose loss changes what the unit DOES (design §8.9 reasons). `status` reports an installed unit missing
|
|
76
|
+
# any of them; the golden test has a sabotage arm per line.
|
|
77
|
+
UNIT_CONTRACT = ('SuccessExitStatus=3', 'KillSignal=SIGINT', 'KillMode=process', 'Restart=on-failure',
|
|
78
|
+
'Slice=lanes.slice', 'After=lanes-statusd.service', 'Wants=lanes-statusd.service',
|
|
79
|
+
' studio host --config "')
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Refusal(Exception):
|
|
83
|
+
"""A typed refusal: `refused <code>: <sentence>` on stderr, exit `code_exit` (3 unless said otherwise)."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, code, sentence, exit_code=3):
|
|
86
|
+
super().__init__(sentence)
|
|
87
|
+
self.code, self.sentence, self.exit_code = code, sentence, exit_code
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class EnrolmentRefused(Refusal):
|
|
91
|
+
"""`studio adopt-receipt` or `studio enroll` did not enrol this machine (typed or not): the units are still
|
|
92
|
+
installed waiting, so the enrol timer can retry (review round 3)."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------------------------------------ paths
|
|
96
|
+
|
|
97
|
+
class Layout:
|
|
98
|
+
"""Every path this module touches, from one environment. Mirrors throttle-rc's RuntimePaths (dirs crate:
|
|
99
|
+
$XDG_CONFIG_HOME|~/.config, $XDG_DATA_HOME|~/.local/share) and the lanes installer's LANES_SHARE/LANES_STATE."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, home, share, state, config_home=None, data_home=None):
|
|
102
|
+
self.home = Path(home)
|
|
103
|
+
self.share = Path(share)
|
|
104
|
+
self.state = Path(state)
|
|
105
|
+
self.config_home = Path(config_home) if config_home else self.home / '.config'
|
|
106
|
+
self.data_home = Path(data_home) if data_home else self.home / '.local/share'
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_env(cls, env=None):
|
|
110
|
+
env = os.environ if env is None else env
|
|
111
|
+
home = Path(env.get('HOME') or Path.home())
|
|
112
|
+
return cls(home, env.get('LANES_SHARE') or home / '.local/share/lanes',
|
|
113
|
+
env.get('LANES_STATE') or home / '.local/state/lanes',
|
|
114
|
+
env.get('XDG_CONFIG_HOME') or None, env.get('XDG_DATA_HOME') or None)
|
|
115
|
+
|
|
116
|
+
config = property(lambda self: self.config_home / 'throttle-rc/studio-host.toml')
|
|
117
|
+
registration_key = property(lambda self: self.config_home / 'throttle-rc/registration.key')
|
|
118
|
+
credential = property(lambda self: self.data_home / 'throttle-rc/studio-host/credential.json')
|
|
119
|
+
machine_key = property(lambda self: self.data_home / 'throttle-rc/machine/key.json') # existence only
|
|
120
|
+
host_state = property(lambda self: self.data_home / 'throttle-rc/studio-host/state.json') # the Host's own
|
|
121
|
+
binary = property(lambda self: self.share / 'bin' / BINARY)
|
|
122
|
+
pin_record = property(lambda self: self.share / 'bin' / PIN_RECORD)
|
|
123
|
+
studio_state = property(lambda self: self.state / 'studio-host')
|
|
124
|
+
public_jwk = property(lambda self: self.studio_state / 'public.jwk')
|
|
125
|
+
receipt = property(lambda self: self.studio_state / 'receipt.json')
|
|
126
|
+
record = property(lambda self: self.studio_state / 'enrolment.json')
|
|
127
|
+
migrated = property(lambda self: self.studio_state / 'migrated')
|
|
128
|
+
units = property(lambda self: self.home / '.config/systemd/user')
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sha256_file(path):
|
|
132
|
+
digest = hashlib.sha256()
|
|
133
|
+
with open(path, 'rb') as handle:
|
|
134
|
+
for block in iter(lambda: handle.read(1 << 20), b''):
|
|
135
|
+
digest.update(block)
|
|
136
|
+
return digest.hexdigest()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def readable_sha(path, code, remedy):
|
|
140
|
+
"""sha256 of a file the installer acts on, or None when it is absent. A file that exists but this account
|
|
141
|
+
cannot read is a typed refusal naming its owner and mode (review round 2: an unreadable receipt crashed every
|
|
142
|
+
timer run with a PermissionError traceback, untyped exit 1, while `status` promised the timer would install it)."""
|
|
143
|
+
if not path.is_file():
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
return sha256_file(path)
|
|
147
|
+
except OSError as error:
|
|
148
|
+
raise Refusal(code, f'{path} exists but this account cannot read it ({unreadable_detail(path, error)}); '
|
|
149
|
+
+ remedy)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def unreadable_detail(path, error):
|
|
153
|
+
try:
|
|
154
|
+
stat = os.stat(path)
|
|
155
|
+
owner = f'owner uid {stat.st_uid}, mode {stat.st_mode & 0o7777:04o}'
|
|
156
|
+
except OSError:
|
|
157
|
+
owner = 'owner and mode unknown'
|
|
158
|
+
return f'{error.strerror or error}; {owner}; this account is uid {os.getuid()}'
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def key_fingerprint(path, remedy):
|
|
162
|
+
"""The registration key's identity for the timer's hour-long memory, WITHOUT reading it (design §8.10 C5;
|
|
163
|
+
review round 5 — this used to hash the secret's bytes into the enrolment record). None when absent. A file this
|
|
164
|
+
account may not read is the typed `key_unreadable` refusal, decided by access(2): the file is never opened."""
|
|
165
|
+
if not path.is_file():
|
|
166
|
+
return None
|
|
167
|
+
if not os.access(path, os.R_OK):
|
|
168
|
+
denied = PermissionError(errno.EACCES, os.strerror(errno.EACCES))
|
|
169
|
+
raise Refusal('key_unreadable', f'{path} exists but this account cannot read it '
|
|
170
|
+
f'({unreadable_detail(path, denied)}); ' + remedy)
|
|
171
|
+
st = os.stat(path)
|
|
172
|
+
# st_ctime_ns too (review round 6): a chmod/chown that FIXES a refused key changes no byte and no mtime, and the
|
|
173
|
+
# timer skipped the fixed file for the rest of the hour.
|
|
174
|
+
return f'{st.st_dev}:{st.st_ino}:{st.st_size}:{st.st_mtime_ns}:{st.st_ctime_ns}'
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def read_config_text(layout):
|
|
178
|
+
"""studio-host.toml's text, or the typed `config_unreadable` refusal (review round 5: an unreadable config
|
|
179
|
+
ended enable, the timer and `status` in a PermissionError traceback before the typed arm could be reached)."""
|
|
180
|
+
try:
|
|
181
|
+
return layout.config.read_text()
|
|
182
|
+
except OSError as error:
|
|
183
|
+
raise Refusal('config_unreadable', f'{layout.config} exists but this account cannot read it '
|
|
184
|
+
f'({unreadable_detail(layout.config, error)}); it must be owned by this account (0600)')
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def is_readable(path):
|
|
188
|
+
"""True when `path` opens for reading (a stat is not a readability check)."""
|
|
189
|
+
try:
|
|
190
|
+
with open(path, 'rb'):
|
|
191
|
+
return True
|
|
192
|
+
except OSError:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def b64url(data):
|
|
197
|
+
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def jwk_thumbprint(jwk):
|
|
201
|
+
"""RFC 7638 thumbprint (base64url SHA-256) of the Ed25519 public JWK `throttle-rc studio publish-key` writes;
|
|
202
|
+
None for anything else. The same computation as the binary's `machine_jkt` (pinned against the real binary)."""
|
|
203
|
+
if not (isinstance(jwk, dict) and jwk.get('kty') == 'OKP' and jwk.get('crv') == 'Ed25519'
|
|
204
|
+
and isinstance(jwk.get('x'), str) and jwk['x']):
|
|
205
|
+
return None
|
|
206
|
+
canonical = json.dumps({'crv': 'Ed25519', 'kty': 'OKP', 'x': jwk['x']}, separators=(',', ':'), sort_keys=True)
|
|
207
|
+
return b64url(hashlib.sha256(canonical.encode()).digest())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_credential(layout):
|
|
211
|
+
"""credential.json as a dict ({} when present but unparsable), or None when absent. It is inert: no secret."""
|
|
212
|
+
if not layout.credential.exists():
|
|
213
|
+
return None
|
|
214
|
+
document = read_json(layout.credential)
|
|
215
|
+
return document if isinstance(document, dict) else {}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def key_binding(layout, credential):
|
|
219
|
+
"""Whether `credential` binds the machine key this installer last PUBLISHED: True/False, or None when that is
|
|
220
|
+
not measurable here (no recorded thumbprint — a version-1 receipt —, no key, no published key, or a published
|
|
221
|
+
key OLDER than the key file, i.e. not this key's). The machine key itself is never read (C5): the published
|
|
222
|
+
public key is the evidence. Review round 6: `enrolled` used to mean "a credential and SOME key", so after the
|
|
223
|
+
timer minted a new key beside a dead receipt, `status` called it `enrolled`/`stopped`."""
|
|
224
|
+
issued = (credential or {}).get('machine_jkt')
|
|
225
|
+
if not issued or not layout.machine_key.exists() or not layout.public_jwk.is_file():
|
|
226
|
+
return None
|
|
227
|
+
try:
|
|
228
|
+
if layout.public_jwk.stat().st_mtime_ns < layout.machine_key.stat().st_mtime_ns:
|
|
229
|
+
return None
|
|
230
|
+
except OSError:
|
|
231
|
+
return None
|
|
232
|
+
published = jwk_thumbprint(read_json(layout.public_jwk))
|
|
233
|
+
return None if published is None else published == issued
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def measured_revoked(layout, server):
|
|
237
|
+
"""Whether THIS machine MEASURED its Host row at `server` as revoked: a `refused` state.json for that server
|
|
238
|
+
whose detail carries a provenance mark and the word REVOKED (throttle-rc's own `measured_revocation`)."""
|
|
239
|
+
state = read_json(layout.host_state)
|
|
240
|
+
if not (isinstance(state, dict) and state.get('phase') == 'refused' and server):
|
|
241
|
+
return False
|
|
242
|
+
if str(state.get('server') or '').rstrip('/') != str(server).rstrip('/'):
|
|
243
|
+
return False
|
|
244
|
+
detail = str(state.get('detail') or '')
|
|
245
|
+
return (PLATFORM_REFUSAL_MARK in detail or SELF_REVOKE_MARK in detail) and 'REVOKED' in detail
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def atomic_write(path, data, mode):
|
|
249
|
+
"""Write next to `path` and rename over it; never through an existing symlink."""
|
|
250
|
+
path = Path(path)
|
|
251
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '.', dir=path.parent)
|
|
253
|
+
try:
|
|
254
|
+
with os.fdopen(fd, 'wb') as handle:
|
|
255
|
+
handle.write(data if isinstance(data, bytes) else data.encode())
|
|
256
|
+
os.chmod(temporary, mode)
|
|
257
|
+
os.replace(temporary, path)
|
|
258
|
+
finally:
|
|
259
|
+
if os.path.exists(temporary):
|
|
260
|
+
os.unlink(temporary)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read_json(path):
|
|
264
|
+
try:
|
|
265
|
+
return json.loads(Path(path).read_text())
|
|
266
|
+
except (OSError, ValueError):
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def now_utc():
|
|
271
|
+
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# -------------------------------------------------------------------------------------------- the pin
|
|
275
|
+
|
|
276
|
+
class Pin:
|
|
277
|
+
def __init__(self, name, version, sha256, url, opt_in=False, member=None, binary_sha256=None):
|
|
278
|
+
self.name, self.version, self.sha256, self.url = name, version, sha256, url
|
|
279
|
+
self.opt_in, self.member, self.binary_sha256 = opt_in, member, binary_sha256
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def parse_lock(text):
|
|
283
|
+
"""holders.lock -> {name: Pin}. `name version sha256 url` for an always-installed holder;
|
|
284
|
+
`... opt-in member member-sha256` for a pin only its opt-in step installs. Malformed lines are refused by
|
|
285
|
+
line number: a lock is an authority, never best-effort."""
|
|
286
|
+
pins = {}
|
|
287
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
288
|
+
if not line.strip() or line.lstrip().startswith('#'):
|
|
289
|
+
continue
|
|
290
|
+
words = line.split()
|
|
291
|
+
if len(words) == 4:
|
|
292
|
+
name, version, sha, url = words
|
|
293
|
+
pin = Pin(name, version, sha, url)
|
|
294
|
+
elif len(words) == 7 and words[4] == LOCK_OPT_IN:
|
|
295
|
+
name, version, sha, url, _flag, member, binary_sha = words
|
|
296
|
+
if not HEX64.match(binary_sha):
|
|
297
|
+
raise Refusal('lock_invalid', f'holders.lock line {number}: the member digest is not 64 lowercase hex')
|
|
298
|
+
parts = member.split('/')
|
|
299
|
+
if member.startswith('/') or any(part in ('', '.', '..') for part in parts):
|
|
300
|
+
raise Refusal('lock_invalid', f'holders.lock line {number}: member {member!r} is not a plain relative path')
|
|
301
|
+
pin = Pin(name, version, sha, url, True, member, binary_sha)
|
|
302
|
+
else:
|
|
303
|
+
raise Refusal('lock_invalid', f'holders.lock line {number}: expected 4 fields, or 7 with "{LOCK_OPT_IN}"')
|
|
304
|
+
if not HEX64.match(sha):
|
|
305
|
+
raise Refusal('lock_invalid', f'holders.lock line {number}: the tarball digest is not 64 lowercase hex')
|
|
306
|
+
if not VERSION_WORD.match(version):
|
|
307
|
+
raise Refusal('lock_invalid', f'holders.lock line {number}: version {version!r} is not a plain word')
|
|
308
|
+
if name in pins:
|
|
309
|
+
raise Refusal('lock_invalid', f'holders.lock line {number}: {name} is pinned twice')
|
|
310
|
+
pins[name] = pin
|
|
311
|
+
return pins
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def lock_path():
|
|
315
|
+
return Path(__file__).resolve().parent.parent / 'holders.lock'
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _select(layout, filename, record):
|
|
319
|
+
"""Point bin/throttle-rc at `filename` (in the same directory) and record what it is. Record first: a crash
|
|
320
|
+
between the two leaves a record that `verify_installed` refuses, never a link with no record."""
|
|
321
|
+
atomic_write(layout.pin_record, json.dumps(record, indent=1, sort_keys=True) + '\n', 0o644)
|
|
322
|
+
link = layout.binary
|
|
323
|
+
temporary = link.with_name(f'.{BINARY}.link-{os.getpid()}')
|
|
324
|
+
if temporary.is_symlink() or temporary.exists():
|
|
325
|
+
temporary.unlink()
|
|
326
|
+
temporary.symlink_to(filename)
|
|
327
|
+
os.replace(temporary, link)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _place(layout, source_bytes_path, filename, expected_sha):
|
|
331
|
+
"""Copy to bin/<filename> (0755, atomic) and re-hash the COPY: the digest that counts is the one the unit runs."""
|
|
332
|
+
target = layout.share / 'bin' / filename
|
|
333
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
fd, temporary = tempfile.mkstemp(prefix=f'.{filename}.', dir=target.parent)
|
|
335
|
+
try:
|
|
336
|
+
with os.fdopen(fd, 'wb') as out, open(source_bytes_path, 'rb') as src:
|
|
337
|
+
shutil.copyfileobj(src, out)
|
|
338
|
+
os.chmod(temporary, 0o755)
|
|
339
|
+
actual = sha256_file(temporary)
|
|
340
|
+
if actual != expected_sha:
|
|
341
|
+
raise Refusal('binary_mismatch', f'the copy at {target} hashed {actual}, not {expected_sha}; nothing selected')
|
|
342
|
+
os.replace(temporary, target)
|
|
343
|
+
finally:
|
|
344
|
+
if os.path.exists(temporary):
|
|
345
|
+
os.unlink(temporary)
|
|
346
|
+
return target
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def install_local(layout, source, expected_sha=None):
|
|
350
|
+
"""`install --from PATH [--sha256 HEX]`: a locally built binary, for a lanes server before a node/<sha12>
|
|
351
|
+
release carries the co-location CLI."""
|
|
352
|
+
source = Path(source)
|
|
353
|
+
if source.is_symlink() or not source.is_file():
|
|
354
|
+
raise Refusal('usage', f'{source} is not a regular file (a symbolic link is refused: name the file itself)', 2)
|
|
355
|
+
if not os.access(source, os.X_OK):
|
|
356
|
+
raise Refusal('usage', f'{source} is not executable', 2)
|
|
357
|
+
if expected_sha is not None and not HEX64.match(expected_sha):
|
|
358
|
+
raise Refusal('usage', '--sha256 must be 64 lowercase hex', 2)
|
|
359
|
+
actual = sha256_file(source)
|
|
360
|
+
if expected_sha is not None and actual != expected_sha:
|
|
361
|
+
raise Refusal('binary_mismatch', f'{source} hashed {actual}, not the --sha256 given; nothing installed')
|
|
362
|
+
version = f'local-{actual[:12]}'
|
|
363
|
+
_place(layout, source, f'{BINARY}-{version}', actual)
|
|
364
|
+
record = {'source': 'local', 'version': version, 'sha256': actual, 'from': str(source.resolve()),
|
|
365
|
+
'installed_at': now_utc()}
|
|
366
|
+
_select(layout, f'{BINARY}-{version}', record)
|
|
367
|
+
return record
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def fetch(url, dest, timeout=300):
|
|
371
|
+
"""Download one release asset. A private repository's asset needs `gh` (its own login); anything else is curl."""
|
|
372
|
+
found = re.match(r'^https://github\.com/([^/]+/[^/]+)/releases/download/(.+)/([^/]+)$', url)
|
|
373
|
+
if found and shutil.which('gh'):
|
|
374
|
+
repo, tag, asset = found.groups()
|
|
375
|
+
argv = ['gh', 'release', 'download', tag, '--repo', repo, '--pattern', asset, '--dir', str(Path(dest).parent),
|
|
376
|
+
'--clobber']
|
|
377
|
+
produced = Path(dest).parent / asset
|
|
378
|
+
else:
|
|
379
|
+
argv = ['curl', '-fsSL', '--retry', '3', '-o', str(dest), url]
|
|
380
|
+
produced = Path(dest)
|
|
381
|
+
try:
|
|
382
|
+
result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout,
|
|
383
|
+
env=child_env({k: os.environ[k] for k in ('GH_TOKEN', 'GITHUB_TOKEN') if os.environ.get(k)}))
|
|
384
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
385
|
+
raise Refusal('download_failed', f'{argv[0]}: {error}', 1)
|
|
386
|
+
if result.returncode != 0:
|
|
387
|
+
raise Refusal('download_failed', f'{argv[0]} exit {result.returncode}: {result.stderr.strip()[:300]}', 1)
|
|
388
|
+
if produced != Path(dest):
|
|
389
|
+
os.replace(produced, dest)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def install_pinned(layout, pin, fetcher=fetch):
|
|
393
|
+
"""`install` with no --from: the holders.lock opt-in pin. The tarball digest AND the member digest must match;
|
|
394
|
+
only the named member is read (no extractall, no links)."""
|
|
395
|
+
downloads = layout.share / 'dl'
|
|
396
|
+
downloads.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
archive = downloads / f'{pin.name}-{pin.version}.tar.gz'
|
|
398
|
+
fetcher(pin.url, archive)
|
|
399
|
+
actual = sha256_file(archive)
|
|
400
|
+
if actual != pin.sha256:
|
|
401
|
+
raise Refusal('binary_mismatch', f'{archive.name} hashed {actual}, not the pinned {pin.sha256}; nothing installed')
|
|
402
|
+
with tempfile.TemporaryDirectory(dir=downloads) as scratch:
|
|
403
|
+
extracted = Path(scratch) / BINARY
|
|
404
|
+
try:
|
|
405
|
+
with tarfile.open(archive, 'r:gz') as tar:
|
|
406
|
+
try:
|
|
407
|
+
member = tar.getmember(pin.member)
|
|
408
|
+
except KeyError:
|
|
409
|
+
raise Refusal('binary_mismatch', f'{archive.name} holds no {pin.member}')
|
|
410
|
+
if not member.isfile():
|
|
411
|
+
raise Refusal('binary_mismatch', f'{pin.member} in {archive.name} is not a regular file')
|
|
412
|
+
with tar.extractfile(member) as src, open(extracted, 'wb') as out:
|
|
413
|
+
shutil.copyfileobj(src, out)
|
|
414
|
+
except (tarfile.TarError, OSError) as error:
|
|
415
|
+
raise Refusal('binary_mismatch', f'{archive.name} is not a readable tar.gz: {error}')
|
|
416
|
+
member_sha = sha256_file(extracted)
|
|
417
|
+
if member_sha != pin.binary_sha256:
|
|
418
|
+
raise Refusal('binary_mismatch', f'{pin.member} hashed {member_sha}, not the pinned {pin.binary_sha256}')
|
|
419
|
+
_place(layout, extracted, f'{BINARY}-{pin.version}', member_sha)
|
|
420
|
+
record = {'source': 'lock', 'version': pin.version, 'sha256': member_sha, 'from': pin.url,
|
|
421
|
+
'installed_at': now_utc()}
|
|
422
|
+
_select(layout, f'{BINARY}-{pin.version}', record)
|
|
423
|
+
return record
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def verify_installed(layout):
|
|
427
|
+
"""-> (word, detail, record). word: verified | absent | mismatch. Hashes the file the link names NOW."""
|
|
428
|
+
record = read_json(layout.pin_record)
|
|
429
|
+
if not layout.binary.is_symlink() and not layout.binary.exists():
|
|
430
|
+
return 'absent', f'no {layout.binary}', record
|
|
431
|
+
if not isinstance(record, dict) or not HEX64.match(str(record.get('sha256', ''))):
|
|
432
|
+
return 'mismatch', f'{layout.pin_record} is missing or unreadable: nothing says what {layout.binary} should be', record
|
|
433
|
+
try:
|
|
434
|
+
target = layout.binary.resolve(strict=True)
|
|
435
|
+
actual = sha256_file(target)
|
|
436
|
+
except OSError as error:
|
|
437
|
+
return 'absent', f'{layout.binary}: {error}', record
|
|
438
|
+
if actual != record['sha256']:
|
|
439
|
+
return 'mismatch', f'{target} hashed {actual}, the install recorded {record["sha256"]}', record
|
|
440
|
+
return 'verified', f"{target.name} ({record.get('source')}, sha256 {actual[:16]}…)", record
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ------------------------------------------------------------------------------------------- config
|
|
444
|
+
|
|
445
|
+
def validate_server(server):
|
|
446
|
+
"""The Host's own rule (studio_host::config::validate_server): scheme + host[:port], nothing after."""
|
|
447
|
+
if not server or not re.match(r'^https?://[^/\s?#]+$', server) or any(ord(c) < 32 for c in server):
|
|
448
|
+
raise Refusal('usage', f'--server {server!r} must be http(s)://host[:port] with nothing after it', 2)
|
|
449
|
+
return server
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def default_machine_id():
|
|
453
|
+
base = re.sub(r'[^A-Za-z0-9._-]', '-', os.uname().nodename.split('.')[0])[:57] or 'lanes'
|
|
454
|
+
return f'{base}-lanes'
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def toml_string(value):
|
|
458
|
+
value = str(value)
|
|
459
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
460
|
+
raise Refusal('usage', 'configuration values must not contain control characters', 2)
|
|
461
|
+
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def render_config(server, machine_id, state_dir, container_uid=1000):
|
|
465
|
+
validate_server(server)
|
|
466
|
+
if not MACHINE_ID.match(machine_id):
|
|
467
|
+
raise Refusal('usage', f'--machine-id {machine_id!r} is outside 1-64 chars of [A-Za-z0-9._-]', 2)
|
|
468
|
+
return (f'{CONFIG_HEADER} (lanes-server mode, docs/design/throttle-studio.md §8.10 C2).\n'
|
|
469
|
+
'# The installer never rewrites this file; edit it freely, then `systemctl --user restart '
|
|
470
|
+
f'{UNIT}`.\n'
|
|
471
|
+
f'server = {toml_string(server)}\n'
|
|
472
|
+
f'machine_id = {toml_string(machine_id)}\n'
|
|
473
|
+
'\n[inventory]\n'
|
|
474
|
+
'mode = "lanes-server"\n'
|
|
475
|
+
'interactive = true\n'
|
|
476
|
+
f'sharing_state_dir = {toml_string(state_dir)}\n'
|
|
477
|
+
f'sharing_container_uid = {int(container_uid)}\n'
|
|
478
|
+
'legacy_coder_launch = false\n')
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
_ASSIGN = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*$')
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _scalar(raw):
|
|
485
|
+
raw = raw.split(' #', 1)[0].strip()
|
|
486
|
+
if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
|
|
487
|
+
return raw[1:-1].replace('\\"', '"').replace('\\\\', '\\')
|
|
488
|
+
if raw in ('true', 'false'):
|
|
489
|
+
return raw == 'true'
|
|
490
|
+
return raw
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def config_facts(text):
|
|
494
|
+
"""The keys this installer reasons about — top-level server/machine_id, [inventory] mode/interactive/
|
|
495
|
+
sharing_state_dir — read line by line (Python 3.9 has no tomllib). Not a validator: `studio host --check` is."""
|
|
496
|
+
facts, table = {'inventory': {}}, ''
|
|
497
|
+
for line in text.splitlines():
|
|
498
|
+
stripped = line.strip()
|
|
499
|
+
if not stripped or stripped.startswith('#'):
|
|
500
|
+
continue
|
|
501
|
+
if stripped.startswith('['):
|
|
502
|
+
table = stripped.strip('[]').strip()
|
|
503
|
+
continue
|
|
504
|
+
found = _ASSIGN.match(line)
|
|
505
|
+
if not found:
|
|
506
|
+
continue
|
|
507
|
+
key, value = found.group(1), _scalar(found.group(2))
|
|
508
|
+
if table == '' and key in ('server', 'machine_id'):
|
|
509
|
+
facts[key] = value
|
|
510
|
+
elif table == 'inventory' and key in ('mode', 'interactive', 'sharing_state_dir', 'sharing_container_uid'):
|
|
511
|
+
facts['inventory'][key] = value
|
|
512
|
+
facts['mode'] = facts['inventory'].get('mode', 'in-container')
|
|
513
|
+
return facts
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def set_lanes_server_mode(text, state_dir):
|
|
517
|
+
"""Return `text` with [inventory] mode = "lanes-server" and a sharing_state_dir (the mode requires one).
|
|
518
|
+
Every other line is kept byte for byte."""
|
|
519
|
+
lines = text.splitlines()
|
|
520
|
+
start = next((i for i, line in enumerate(lines) if line.strip() == '[inventory]'), None)
|
|
521
|
+
if start is None:
|
|
522
|
+
tail = ['', '[inventory]', 'mode = "lanes-server"', f'sharing_state_dir = {toml_string(state_dir)}']
|
|
523
|
+
return '\n'.join(lines + tail) + '\n'
|
|
524
|
+
end = next((i for i in range(start + 1, len(lines)) if lines[i].strip().startswith('[')), len(lines))
|
|
525
|
+
section = lines[start + 1:end]
|
|
526
|
+
keys = {(_ASSIGN.match(line) or [None, None])[1]: i for i, line in enumerate(section)
|
|
527
|
+
if not line.strip().startswith('#')}
|
|
528
|
+
if 'mode' in keys:
|
|
529
|
+
section[keys['mode']] = 'mode = "lanes-server"'
|
|
530
|
+
else:
|
|
531
|
+
section.insert(0, 'mode = "lanes-server"')
|
|
532
|
+
if 'sharing_state_dir' not in keys:
|
|
533
|
+
section.insert(1, f'sharing_state_dir = {toml_string(state_dir)}')
|
|
534
|
+
return '\n'.join(lines[:start + 1] + section + lines[end:]) + '\n'
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ----------------------------------------------------------------------------------- external calls
|
|
538
|
+
|
|
539
|
+
RUNTIME_DIR_ROOT = Path('/run/user')
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def child_env(extra=None):
|
|
543
|
+
"""The allowlisted environment. Review round 2: like bin/lanes' clean_env() and the install scripts, a caller
|
|
544
|
+
without XDG_RUNTIME_DIR (a `sudo -u <account> -i` or cron shell) gets /run/user/<uid> when it exists, or every
|
|
545
|
+
`systemctl --user` answer is `no-answer` and `status` says `unknown` for a running Host."""
|
|
546
|
+
env = {key: os.environ[key] for key in ENV_ALLOWLIST if os.environ.get(key)}
|
|
547
|
+
runtime = RUNTIME_DIR_ROOT / str(os.getuid())
|
|
548
|
+
if 'XDG_RUNTIME_DIR' not in env and runtime.is_dir():
|
|
549
|
+
env['XDG_RUNTIME_DIR'] = str(runtime)
|
|
550
|
+
env.update(extra or {})
|
|
551
|
+
return env
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def run(argv, timeout, extra_env=None):
|
|
555
|
+
"""-> (rc, stdout, stderr); rc None when it did not finish in `timeout` seconds, 127 when it could not spawn."""
|
|
556
|
+
try:
|
|
557
|
+
result = subprocess.run(argv, capture_output=True, text=True, errors='replace', timeout=timeout,
|
|
558
|
+
env=child_env(extra_env), stdin=subprocess.DEVNULL)
|
|
559
|
+
return result.returncode, result.stdout, result.stderr
|
|
560
|
+
except subprocess.TimeoutExpired as error:
|
|
561
|
+
return None, error.stdout or '', error.stderr or ''
|
|
562
|
+
except OSError as error:
|
|
563
|
+
return 127, '', str(error)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def systemctl(*args, timeout=SYSTEMCTL_TIMEOUT):
|
|
567
|
+
return run(['systemctl', '--user', *args], timeout)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def unit_word(verb, unit):
|
|
571
|
+
"""`is-active`/`is-enabled` -> its first word, or 'no-answer' (timeout / no manager). Never invented."""
|
|
572
|
+
rc, out, err = systemctl(verb, unit, timeout=10)
|
|
573
|
+
word = (out.strip().splitlines() or [''])[0].strip()
|
|
574
|
+
if rc is None or rc == 127:
|
|
575
|
+
return 'no-answer'
|
|
576
|
+
if not word and 'No such file' in (err or ''):
|
|
577
|
+
return 'not-found' # older managers answer a missing unit on stderr only
|
|
578
|
+
return word or 'no-answer'
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def manager_units_dir(layout):
|
|
582
|
+
"""Where the user MANAGER reads unit files (systemd.unit(5)): $XDG_CONFIG_HOME/systemd/user when the manager's
|
|
583
|
+
own environment sets it, else ~/.config/systemd/user. Asked of the manager (`show-environment`), never of this
|
|
584
|
+
shell (review round 2: a manager with XDG_CONFIG_HOME set never loads ~/.config/systemd/user, so the enable
|
|
585
|
+
failed `Unit not found` while status said the file was present). No answer keeps the default."""
|
|
586
|
+
rc, out, _err = systemctl('show-environment', timeout=10)
|
|
587
|
+
if rc == 0:
|
|
588
|
+
for line in (out or '').splitlines():
|
|
589
|
+
if line.startswith('XDG_CONFIG_HOME='):
|
|
590
|
+
value = line.split('=', 1)[1]
|
|
591
|
+
if value.startswith('/'):
|
|
592
|
+
return Path(value) / 'systemd/user'
|
|
593
|
+
return layout.units
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def layout_roots(layout):
|
|
597
|
+
"""The two XDG roots every throttle-rc child — and both studio units (install/services.py) — resolve with:
|
|
598
|
+
this layout's, ALWAYS set. Review round 3: inherited from whoever ran the step, a user manager whose environment
|
|
599
|
+
carries XDG_DATA_HOME the installer's shell does not made the supervised Host look for credential.json where the
|
|
600
|
+
installer never wrote it, while the installer's own `--check` passed."""
|
|
601
|
+
return {'XDG_CONFIG_HOME': str(layout.config_home), 'XDG_DATA_HOME': str(layout.data_home)}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def throttle_rc(layout, *args, timeout=60):
|
|
605
|
+
return run([str(layout.binary), *args], timeout, extra_env=layout_roots(layout))
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def hand_built_text(layout):
|
|
609
|
+
"""The hand-built unit's text as the manager has it (`systemctl --user cat`), or None when there is none."""
|
|
610
|
+
rc, out, _err = systemctl('cat', HAND_BUILT_UNIT, timeout=10)
|
|
611
|
+
if rc == 0 and out.strip():
|
|
612
|
+
return out
|
|
613
|
+
path = manager_units_dir(layout) / HAND_BUILT_UNIT
|
|
614
|
+
return path.read_text() if path.is_file() else None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def expand_unit_path(value, home):
|
|
618
|
+
"""A unit's path argument as the user manager resolves it: `%h` (and `%%`), `${HOME}`/`$HOME` and `~` are the
|
|
619
|
+
account's home (review round 2: an idiomatic `--config %h/...` unit was refused as naming another file)."""
|
|
620
|
+
home = str(home)
|
|
621
|
+
value = value.replace('%%', '\0').replace('%h', home).replace('\0', '%')
|
|
622
|
+
value = re.sub(r'\$\{HOME\}|\$HOME(?![A-Za-z0-9_])', lambda _m: home, value)
|
|
623
|
+
if value == '~' or value.startswith('~/'):
|
|
624
|
+
value = home + value[1:]
|
|
625
|
+
return Path(value)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def hand_built_config(text):
|
|
629
|
+
"""-> (runs_studio_host, config path or None) from the unit's ExecStart lines."""
|
|
630
|
+
for line in text.splitlines():
|
|
631
|
+
if not line.strip().startswith('ExecStart='):
|
|
632
|
+
continue
|
|
633
|
+
command = line.split('=', 1)[1]
|
|
634
|
+
if ' studio host' not in command:
|
|
635
|
+
continue
|
|
636
|
+
found = re.search(r'--config[ =]"?([^"\s]+)"?', command)
|
|
637
|
+
return True, (found.group(1) if found else None)
|
|
638
|
+
return False, None
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ------------------------------------------------------------------------------------------- enable
|
|
642
|
+
|
|
643
|
+
USAGE = """usage: sh install/enable-studio-host.sh [options]
|
|
644
|
+
--server URL the platform the Host dials (required only when no config exists yet)
|
|
645
|
+
--machine-id ID the config's machine_id (default: <hostname>-lanes)
|
|
646
|
+
--container-uid N the workspace containers' tmux uid (default 1000)
|
|
647
|
+
--receipt PATH a deploy receipt to adopt (default: $LANES_STATE/studio-host/receipt.json);
|
|
648
|
+
remembered, so the enrol timer and `status` watch it (the default path forgets it)
|
|
649
|
+
--registration-key-file PATH enrol with the owner's registration key (default: ~/.config/throttle-rc/registration.key);
|
|
650
|
+
remembered like --receipt
|
|
651
|
+
--set-mode an existing config is not in lanes-server mode: rewrite that one key (keeps a .bak)
|
|
652
|
+
--migrate-hand-built replace the hand-built lanes-sharing-host.service (its text is saved first)
|
|
653
|
+
--from PATH [--sha256 HEX] install this locally built throttle-rc first (else the holders.lock pin)
|
|
654
|
+
--settle SECONDS how long a started Host must stay running (default 5)
|
|
655
|
+
--dry-run print the plan; write, enable and start nothing
|
|
656
|
+
--retry what lanes-studio-host-enrol.timer runs: act only on something new
|
|
657
|
+
Exit: 0 running or waiting for enrolment; 2 usage; 3 a typed refusal; 1 an operational failure. A refused or
|
|
658
|
+
failed receipt/key still leaves the units enabled and waiting (the exit says what was refused)."""
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def parse_enable_args(argv):
|
|
662
|
+
options = {'server': None, 'machine_id': None, 'container_uid': 1000, 'receipt': None, 'key_file': None,
|
|
663
|
+
'set_mode': False, 'migrate': False, 'from': None, 'sha256': None, 'settle': 5.0,
|
|
664
|
+
'dry_run': False, 'retry': False}
|
|
665
|
+
flags = {'--set-mode': 'set_mode', '--migrate-hand-built': 'migrate', '--dry-run': 'dry_run', '--retry': 'retry'}
|
|
666
|
+
values = {'--server': 'server', '--machine-id': 'machine_id', '--container-uid': 'container_uid',
|
|
667
|
+
'--receipt': 'receipt', '--registration-key-file': 'key_file', '--from': 'from', '--sha256': 'sha256',
|
|
668
|
+
'--settle': 'settle'}
|
|
669
|
+
items = list(argv)
|
|
670
|
+
while items:
|
|
671
|
+
item = items.pop(0)
|
|
672
|
+
if item in ('-h', '--help'):
|
|
673
|
+
print(USAGE)
|
|
674
|
+
raise SystemExit(0)
|
|
675
|
+
if item in flags:
|
|
676
|
+
options[flags[item]] = True
|
|
677
|
+
elif item in values:
|
|
678
|
+
if not items:
|
|
679
|
+
raise Refusal('usage', f'{item} needs a value\n{USAGE}', 2)
|
|
680
|
+
options[values[item]] = items.pop(0)
|
|
681
|
+
else:
|
|
682
|
+
raise Refusal('usage', f'unknown option {item!r}\n{USAGE}', 2)
|
|
683
|
+
try:
|
|
684
|
+
options['container_uid'] = int(options['container_uid'])
|
|
685
|
+
options['settle'] = float(options['settle'])
|
|
686
|
+
except ValueError:
|
|
687
|
+
raise Refusal('usage', '--container-uid and --settle take numbers', 2)
|
|
688
|
+
if options['sha256'] and not options['from']:
|
|
689
|
+
raise Refusal('usage', '--sha256 goes with --from', 2)
|
|
690
|
+
return options
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
class Plan:
|
|
694
|
+
"""Prints each step; in a dry run, says what it WOULD do and does nothing."""
|
|
695
|
+
|
|
696
|
+
def __init__(self, dry_run, quiet=False):
|
|
697
|
+
self.dry_run, self.quiet = dry_run, quiet
|
|
698
|
+
|
|
699
|
+
def say(self, text):
|
|
700
|
+
if not self.quiet:
|
|
701
|
+
print(text)
|
|
702
|
+
|
|
703
|
+
def step(self, text, action=None):
|
|
704
|
+
if self.dry_run:
|
|
705
|
+
print(f'would: {text}')
|
|
706
|
+
return None
|
|
707
|
+
self.say(f'{text}')
|
|
708
|
+
return action() if action else None
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _record(layout):
|
|
712
|
+
return read_json(layout.record) or {}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def receipt_path(layout, record):
|
|
716
|
+
"""The receipt this machine watches: the remembered --receipt, else the default."""
|
|
717
|
+
return Path(record['receipt_path']) if record.get('receipt_path') else layout.receipt
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def key_path(layout, record):
|
|
721
|
+
"""The registration key file this machine watches: the remembered --registration-key-file, else the default."""
|
|
722
|
+
return Path(record['registration_key_path']) if record.get('registration_key_path') else layout.registration_key
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _remember_paths(layout, options, record):
|
|
726
|
+
"""A manual run's --receipt/--registration-key-file is remembered (an explicit default forgets it)."""
|
|
727
|
+
defaults = {'receipt': layout.receipt, 'key_file': layout.registration_key}
|
|
728
|
+
changed = False
|
|
729
|
+
for option, key in REMEMBERED_PATHS.items():
|
|
730
|
+
if not options[option]:
|
|
731
|
+
continue
|
|
732
|
+
chosen = Path(options[option]).absolute()
|
|
733
|
+
value = None if chosen == defaults[option] else str(chosen)
|
|
734
|
+
if record.get(key) != value:
|
|
735
|
+
if value is None:
|
|
736
|
+
record.pop(key, None)
|
|
737
|
+
else:
|
|
738
|
+
record[key] = value
|
|
739
|
+
changed = True
|
|
740
|
+
if changed:
|
|
741
|
+
_save_record(layout, record)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _save_record(layout, record):
|
|
745
|
+
record.pop(LEGACY_KEY_DIGEST, None)
|
|
746
|
+
layout.studio_state.mkdir(parents=True, exist_ok=True)
|
|
747
|
+
os.chmod(layout.studio_state, 0o700)
|
|
748
|
+
atomic_write(layout.record, json.dumps(record, indent=1, sort_keys=True) + '\n', 0o600)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _check_systemctl(rc, out, err, what):
|
|
752
|
+
if rc != 0:
|
|
753
|
+
raise Refusal('systemd_failed', f'systemctl --user {what}: exit {rc}: {(err or out).strip()[:300]}', 1)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _bounded_detail(text, limit=600):
|
|
757
|
+
"""A child's output for a refusal line: its HEAD and its tail when it is long (review round 6: a tail-only cut
|
|
758
|
+
dropped the head of the binary's one refusal line, `--registration-key-file …` reading `n-key-file …`)."""
|
|
759
|
+
text = (text or '').strip()
|
|
760
|
+
if len(text) <= limit:
|
|
761
|
+
return text or '(no output)'
|
|
762
|
+
half = limit // 2
|
|
763
|
+
return f'{text[:half]} … {text[-half:]}'
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _throttle_rc_failed(verb, rc, out, err, kind=None, typed_exits=(3,)):
|
|
767
|
+
"""The installer's refusal for a failed throttle-rc verb. Exit 3 (typed) when the child's exit is one of
|
|
768
|
+
`typed_exits` — `studio enroll` types a refused key SOURCE as its usage exit 2 (`registration_key_source_refused`,
|
|
769
|
+
review round 6), which is a refusal, not "an operational failure" — else 1."""
|
|
770
|
+
kind = kind or Refusal
|
|
771
|
+
detail = _bounded_detail(err or out)
|
|
772
|
+
if rc is None:
|
|
773
|
+
return kind('throttle_rc_failed', f'throttle-rc studio {verb} did not finish in time', 1)
|
|
774
|
+
return kind('throttle_rc_failed', f'throttle-rc studio {verb} exit {rc}: {detail}', 3 if rc in typed_exits else 1)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _publish(layout):
|
|
778
|
+
"""`throttle-rc studio publish-key` into $LANES_STATE/studio-host (0700). Idempotent for an existing machine key
|
|
779
|
+
(it is loaded, never rewritten); a key is created here only when there is none."""
|
|
780
|
+
layout.studio_state.mkdir(parents=True, exist_ok=True)
|
|
781
|
+
os.chmod(layout.studio_state, 0o700)
|
|
782
|
+
rc, out, err = throttle_rc(layout, 'studio', 'publish-key', '--out', str(layout.public_jwk), timeout=30)
|
|
783
|
+
if rc != 0:
|
|
784
|
+
raise _throttle_rc_failed('publish-key', rc, out, err)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _ensure_binary(layout, options, plan):
|
|
788
|
+
"""The binary the unit runs is installed AND its file still hashes to what was recorded."""
|
|
789
|
+
planned = False
|
|
790
|
+
if options['from']:
|
|
791
|
+
plan.step(f"install {options['from']} as the Host binary (sha256-verified)",
|
|
792
|
+
lambda: install_local(layout, options['from'], options['sha256']))
|
|
793
|
+
planned = True
|
|
794
|
+
word, detail, _pin = verify_installed(layout)
|
|
795
|
+
if word == 'absent' and not options['from'] and not options['retry']:
|
|
796
|
+
pins = parse_lock(lock_path().read_text())
|
|
797
|
+
if BINARY not in pins:
|
|
798
|
+
raise Refusal('no_binary', f'{detail}, and holders.lock pins no {BINARY} release yet. Build it '
|
|
799
|
+
'(cargo build --release --locked -p throttle-rc) and pass --from PATH, or run '
|
|
800
|
+
'`lanes studio-host install --from PATH` first.')
|
|
801
|
+
plan.step(f'install the holders.lock pin {BINARY} {pins[BINARY].version} (both digests checked)',
|
|
802
|
+
lambda: install_pinned(layout, pins[BINARY]))
|
|
803
|
+
planned = True
|
|
804
|
+
word, detail, _pin = verify_installed(layout)
|
|
805
|
+
if word != 'verified' and not (options['dry_run'] and planned):
|
|
806
|
+
raise Refusal('binary_mismatch' if word == 'mismatch' else 'no_binary', detail)
|
|
807
|
+
plan.say(f'binary: {detail}' if word == 'verified' else 'binary: (installed by this run)')
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _one_host_per_machine(layout, options, record):
|
|
811
|
+
"""One Host per machine. Returns the hand-built unit's text when this run migrates it, else None."""
|
|
812
|
+
for verb in ('is-active', 'is-enabled'):
|
|
813
|
+
state = unit_word(verb, SUPERVISOR_UNIT)
|
|
814
|
+
if state in ('active', 'activating', 'reloading', 'enabled', 'enabled-runtime', 'linked'):
|
|
815
|
+
raise Refusal('another_host_unit', f'{SUPERVISOR_UNIT} is {state}: one Studio Host per machine. Remove it '
|
|
816
|
+
'first (`throttle-rc studio service uninstall --yes`), then re-run this.')
|
|
817
|
+
hand_built = hand_built_text(layout)
|
|
818
|
+
if hand_built is None:
|
|
819
|
+
return None
|
|
820
|
+
states = (unit_word('is-active', HAND_BUILT_UNIT), unit_word('is-enabled', HAND_BUILT_UNIT))
|
|
821
|
+
stopped = states[0] in ('inactive', 'failed') and states[1] != 'enabled'
|
|
822
|
+
if record.get('hand_built_migrated_at') and stopped:
|
|
823
|
+
return None # migrated by an earlier run; its file is left for the operator to delete
|
|
824
|
+
if options['retry'] and stopped:
|
|
825
|
+
# Review round 1: the timer never refuses on a stopped, disabled hand-built unit (its migration record may
|
|
826
|
+
# have been lost with $LANES_STATE/studio-host); a manual run still asks for --migrate-hand-built.
|
|
827
|
+
return None
|
|
828
|
+
if not options['migrate']:
|
|
829
|
+
raise Refusal('hand_built_unit_present', f'{HAND_BUILT_UNIT} exists ({states[0]}, {states[1]}). Read it '
|
|
830
|
+
f'(`systemctl --user cat {HAND_BUILT_UNIT}`), then re-run with --migrate-hand-built: its text '
|
|
831
|
+
f'is saved under {layout.migrated}, it is disabled and stopped, and the machine key and '
|
|
832
|
+
'enrolment are kept.')
|
|
833
|
+
runs_host, config_path = hand_built_config(hand_built)
|
|
834
|
+
if not runs_host:
|
|
835
|
+
raise Refusal('hand_built_unit_unrecognised', f'{HAND_BUILT_UNIT} does not run `studio host`; it is not '
|
|
836
|
+
'the shape this migration knows. Nothing was changed.')
|
|
837
|
+
if config_path and expand_unit_path(config_path, layout.home) != layout.config:
|
|
838
|
+
raise Refusal('hand_built_unit_unrecognised', f'{HAND_BUILT_UNIT} runs the Host with --config {config_path};'
|
|
839
|
+
f' the lanes unit reads {layout.config}. Move that file there first. Nothing was changed.')
|
|
840
|
+
return hand_built
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _ensure_config(layout, options, plan):
|
|
844
|
+
"""Step 3: a config in lanes-server mode. Never rewrites an existing file beyond the one `mode` key. Returns the
|
|
845
|
+
.bak it kept when it patched an existing file (the migration restores it if the replacement does not check)."""
|
|
846
|
+
if not layout.config.exists():
|
|
847
|
+
if not options['server']:
|
|
848
|
+
raise Refusal('server_required', f'there is no {layout.config} yet: name the platform with --server URL')
|
|
849
|
+
text = render_config(options['server'], options['machine_id'] or default_machine_id(), str(layout.state),
|
|
850
|
+
options['container_uid'])
|
|
851
|
+
plan.step(f'write {layout.config} in lanes-server mode (0600)', lambda: atomic_write(layout.config, text, 0o600))
|
|
852
|
+
return None
|
|
853
|
+
facts = config_facts(read_config_text(layout))
|
|
854
|
+
if facts['mode'] == 'lanes-server':
|
|
855
|
+
plan.say(f'config: {layout.config} (lanes-server)')
|
|
856
|
+
return None
|
|
857
|
+
if not (options['set_mode'] or options['migrate']):
|
|
858
|
+
raise Refusal('config_mode', f'{layout.config} is in {facts["mode"]} mode. Re-run with --set-mode to rewrite '
|
|
859
|
+
'that one key to lanes-server (a .bak is kept), or edit it yourself.')
|
|
860
|
+
|
|
861
|
+
kept = []
|
|
862
|
+
|
|
863
|
+
def patch():
|
|
864
|
+
text = read_config_text(layout)
|
|
865
|
+
backup = layout.config.with_name(f'{layout.config.name}.bak-{time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())}')
|
|
866
|
+
shutil.copy2(layout.config, backup)
|
|
867
|
+
atomic_write(layout.config, set_lanes_server_mode(text, str(layout.state)), 0o600)
|
|
868
|
+
kept.append(backup)
|
|
869
|
+
print(f'kept the previous file as {backup}')
|
|
870
|
+
plan.step(f'set [inventory] mode = "lanes-server" in {layout.config} (keeping a .bak)', patch)
|
|
871
|
+
return kept[0] if kept else None
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _migrate_hand_built(layout, plan, hand_built, record, replaced):
|
|
875
|
+
"""Step 4: save the text, then disable --now. The key is never touched.
|
|
876
|
+
|
|
877
|
+
Review round 2: the hand-built Host is stopped only after the replacement's own `studio host --check` passes on
|
|
878
|
+
the migrated config. An enrolled machine whose check refuses (a version-1 credential's unchecked key binding, a
|
|
879
|
+
refused state) keeps its hand-built Host running and enabled, the config is restored from the .bak this run
|
|
880
|
+
kept, and the refusal names both. (`--check` is read-only and safe beside a live Host.) An unenrolled machine
|
|
881
|
+
has no serving Host to protect: its check would only say "not enrolled", so it is not run here. The config is
|
|
882
|
+
given back by `enable` (review round 5: on ANY failure before `replaced` is set, not only this one)."""
|
|
883
|
+
def migrate():
|
|
884
|
+
if layout.credential.exists():
|
|
885
|
+
rc, out, err = throttle_rc(layout, 'studio', 'host', '--check', '--config', str(layout.config),
|
|
886
|
+
timeout=60)
|
|
887
|
+
if rc != 0:
|
|
888
|
+
raise Refusal('host_check_failed', f'`throttle-rc studio host --check` exit {rc} on the migrated '
|
|
889
|
+
f'config, so {HAND_BUILT_UNIT} was LEFT running and enabled and nothing was replaced. '
|
|
890
|
+
'Fix what the check names, then re-run with --migrate-hand-built:\n'
|
|
891
|
+
+ (out + err).strip()[-1500:], 3 if rc == 3 else 1)
|
|
892
|
+
layout.migrated.mkdir(parents=True, exist_ok=True)
|
|
893
|
+
saved = layout.migrated / f'{HAND_BUILT_UNIT}.{time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())}'
|
|
894
|
+
atomic_write(saved, hand_built, 0o600)
|
|
895
|
+
_check_systemctl(*systemctl('disable', '--now', HAND_BUILT_UNIT), f'disable --now {HAND_BUILT_UNIT}')
|
|
896
|
+
replaced.append(saved)
|
|
897
|
+
record['hand_built_migrated_at'] = now_utc()
|
|
898
|
+
record['hand_built_saved'] = str(saved)
|
|
899
|
+
_save_record(layout, record)
|
|
900
|
+
print(f'saved {saved}; {HAND_BUILT_UNIT} disabled and stopped (its unit file is left for you to delete)')
|
|
901
|
+
plan.step(f'check the migrated config (`studio host --check`), save {HAND_BUILT_UNIT} under {layout.migrated}, '
|
|
902
|
+
'then disable --now it', migrate)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
RECEIPT_UNREADABLE_REMEDY = ('a deploy run as another account writes it 0600 as that account (DEF-1429): give it to '
|
|
906
|
+
'this account (chown, or chmod 0644 — the receipt is inert), or have the deploy write '
|
|
907
|
+
'it where this account can read it and pass --receipt PATH')
|
|
908
|
+
KEY_UNREADABLE_REMEDY = 'give it to this account (owned by it, mode 0600); it is read by throttle-rc, never by this script'
|
|
909
|
+
REFUSAL_CODE = re.compile(r'refused ([a-z0-9_]+)')
|
|
910
|
+
# `studio enroll`'s line names its code at the END, `<sentence> (<code>)` (enroll.rs EnrollRefusal::text_line).
|
|
911
|
+
TRAILING_CODE = re.compile(r'\(([a-z][a-z0-9_]*_[a-z0-9_]+)\)\s*$', re.MULTILINE)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _refusal_code(text):
|
|
915
|
+
found = REFUSAL_CODE.search(text or '') or TRAILING_CODE.search(text or '')
|
|
916
|
+
return found.group(1) if found else 'unknown'
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _configured_server(layout, options):
|
|
920
|
+
facts = config_facts(read_config_text(layout)) if layout.config.exists() else {}
|
|
921
|
+
return facts.get('server') or options['server'], facts
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _enrol(layout, options, plan, record, receipt, key_file):
|
|
925
|
+
"""Step 5. Returns whether this machine is (or, in a dry run, would be) enrolled.
|
|
926
|
+
|
|
927
|
+
Review round 1: an existing credential short-circuits ONLY while it binds the machine key beside it and no NEW
|
|
928
|
+
deploy receipt has arrived. Review round 6: a credential whose key is gone (or is not the key on disk) is DEAD
|
|
929
|
+
only when this machine has MEASURED its host row revoked (state.json, the binary's own rule) — then a new key is
|
|
930
|
+
published and the next enrolment says `--new-key`. Without that measurement it is a live, un-revoked host row
|
|
931
|
+
whose key went missing (a lost or restored key file, a partial hand remedy): enrolling a second machine beside
|
|
932
|
+
it is the operator's decision, so this refuses `credential_for_gone_key` and names the two steps (revoke, then
|
|
933
|
+
`lanes studio-host re-key --yes`). It used to publish a new key and say `--new-key` by itself."""
|
|
934
|
+
receipt_sha = readable_sha(receipt, 'receipt_unreadable', RECEIPT_UNREADABLE_REMEDY)
|
|
935
|
+
config_sha = readable_sha(layout.config, 'config_unreadable', 'it must be owned by this account (0600)')
|
|
936
|
+
credential = load_credential(layout)
|
|
937
|
+
enrolled = (credential is not None and layout.machine_key.exists()
|
|
938
|
+
and key_binding(layout, credential) is not False)
|
|
939
|
+
# Review round 2: the refusal memory is the TIMER's, as the key's is (design §8.10 C3 (c)). It holds only for a
|
|
940
|
+
# TYPED refusal (exit 3) of THIS receipt under THIS config, for an hour. A manual run always tries a refused
|
|
941
|
+
# receipt (the operator just fixed the Host side; the deploy re-emits byte-identical bytes), and a transient
|
|
942
|
+
# failure (exit 1/2, a timeout, a failed spawn) is never remembered at all. Review round 4: the same holds for an
|
|
943
|
+
# ENROLLED machine — a new receipt (a new host row after a platform reset, a relabel) refused while an old
|
|
944
|
+
# credential.json was on disk used to be skipped for ever, by the timer and by a manual run alike, leaving a dead
|
|
945
|
+
# credential beside a valid receipt.
|
|
946
|
+
refused_before = bool(receipt_sha) and receipt_sha == record.get('receipt_refused_sha')
|
|
947
|
+
refusal_recent = (refused_before and config_sha == record.get('receipt_refused_config_sha')
|
|
948
|
+
and time.time() - record.get('receipt_refused_at', 0) <= RETRY_KEY_AFTER)
|
|
949
|
+
skip_refused = refused_before and options['retry'] and refusal_recent
|
|
950
|
+
fresh_receipt = bool(receipt_sha) and receipt_sha != record.get('receipt_adopted_sha') and not skip_refused
|
|
951
|
+
if skip_refused: # the one line the timer logs
|
|
952
|
+
code = record.get('receipt_refused_code', 'unknown')
|
|
953
|
+
print(f'receipt {receipt} was refused ({code}); the timer tries it again after an hour or when '
|
|
954
|
+
f'{layout.config} changes; a manual run of install/enable-studio-host.sh tries it now')
|
|
955
|
+
if enrolled and not fresh_receipt:
|
|
956
|
+
plan.say(f'enrolment: {layout.credential}')
|
|
957
|
+
return True
|
|
958
|
+
new_key = []
|
|
959
|
+
if credential is not None and not enrolled:
|
|
960
|
+
server, _facts = _configured_server(layout, options)
|
|
961
|
+
what = ('is for a machine key that is gone' if not layout.machine_key.exists()
|
|
962
|
+
else 'is for another machine key than the one on disk')
|
|
963
|
+
host = credential.get('host_id') or 'unrecorded'
|
|
964
|
+
machine = credential.get('machine_id') or 'unrecorded'
|
|
965
|
+
if not measured_revoked(layout, server):
|
|
966
|
+
raise EnrolmentRefused(
|
|
967
|
+
'credential_for_gone_key',
|
|
968
|
+
f'{layout.credential} (Studio Host {host}, machine {machine}) {what}, and this machine has not '
|
|
969
|
+
f'measured that host row revoked: it may still be enrolled — and if its key exists anywhere, able to '
|
|
970
|
+
f'dial. Enrolling now would enrol a SECOND machine beside it, so nothing was published or enrolled. '
|
|
971
|
+
f'Revoke host {host} (Revoke `{machine}` in the console, or `throttle-rc studio revoke --id {host}` '
|
|
972
|
+
'from an owner session), then run `lanes studio-host re-key --yes` — the explicit decision to enrol '
|
|
973
|
+
'this machine under a new key.', 3)
|
|
974
|
+
plan.say(f'enrolment: {layout.credential} {what}; this machine measured host {host} revoked, so the next '
|
|
975
|
+
'enrolment is a new key (`--new-key`)')
|
|
976
|
+
new_key = ['--new-key']
|
|
977
|
+
|
|
978
|
+
# The retry republishes only when there is no key to have published (a re-key): publish-key is idempotent for
|
|
979
|
+
# an existing key, so a published key is re-written only on a manual run.
|
|
980
|
+
if not (options['retry'] and layout.public_jwk.exists() and layout.machine_key.exists()):
|
|
981
|
+
plan.step(f"publish this machine's PUBLIC key to {layout.public_jwk} (`throttle-rc studio publish-key`; the "
|
|
982
|
+
'key is created here if absent and never leaves this machine)', lambda: _publish(layout))
|
|
983
|
+
if fresh_receipt:
|
|
984
|
+
def adopt():
|
|
985
|
+
# --config: the receipt must name the server THIS unit's Host dials (receipt_server_mismatch, exit 3,
|
|
986
|
+
# is refused before anything is installed or recorded as adopted).
|
|
987
|
+
rc, out, err = throttle_rc(layout, 'studio', 'adopt-receipt', '--file', str(receipt), '--config',
|
|
988
|
+
str(layout.config), *new_key, timeout=30)
|
|
989
|
+
if rc == 3:
|
|
990
|
+
# A typed refusal of these bytes under this config: remembered for the TIMER (see above).
|
|
991
|
+
record.update(receipt_refused_sha=receipt_sha, receipt_refused_config_sha=config_sha,
|
|
992
|
+
receipt_refused_at=time.time(), receipt_refused_code=_refusal_code(err or out))
|
|
993
|
+
_save_record(layout, record)
|
|
994
|
+
if rc != 0:
|
|
995
|
+
raise _throttle_rc_failed('adopt-receipt', rc, out, err, EnrolmentRefused)
|
|
996
|
+
for key in RECEIPT_REFUSAL_MEMORY:
|
|
997
|
+
record.pop(key, None)
|
|
998
|
+
record.update(receipt_adopted_sha=receipt_sha, receipt_adopted_at=now_utc())
|
|
999
|
+
_save_record(layout, record)
|
|
1000
|
+
plan.step(f"adopt the deploy receipt {receipt} (`throttle-rc studio adopt-receipt"
|
|
1001
|
+
+ (' --new-key' if new_key else '') + "`; its machine_jkt must be this machine's key and its "
|
|
1002
|
+
"server this config's)", adopt)
|
|
1003
|
+
return True
|
|
1004
|
+
# Review round 6: the key's stat identity is taken only on the registration-key route — an unusable leftover key
|
|
1005
|
+
# file used to refuse `key_unreadable` BEFORE a fresh deploy receipt, which needs no key, could be adopted.
|
|
1006
|
+
# Review round 5: its stat identity, never its bytes — this module does not open the registration key.
|
|
1007
|
+
key_id = key_fingerprint(key_file, KEY_UNREADABLE_REMEDY)
|
|
1008
|
+
# The hour's memory is the TIMER's (design §8.10 C3 (c)): a manual run always tries the key — the operator just
|
|
1009
|
+
# fixed the file or the server and asked (review round 1: the manual re-run the refusal tells them to make was
|
|
1010
|
+
# silently skipped for an hour).
|
|
1011
|
+
key_recent = (key_id == record.get('key_tried_stat')
|
|
1012
|
+
and time.time() - record.get('key_tried_at', 0) <= RETRY_KEY_AFTER)
|
|
1013
|
+
if key_id and not (options['retry'] and key_recent):
|
|
1014
|
+
server, facts = _configured_server(layout, options)
|
|
1015
|
+
label = facts.get('machine_id') or options['machine_id'] or default_machine_id()
|
|
1016
|
+
|
|
1017
|
+
def enrol():
|
|
1018
|
+
for key in KEY_REFUSAL_MEMORY:
|
|
1019
|
+
record.pop(key, None)
|
|
1020
|
+
record.update(key_tried_stat=key_id, key_tried_at=time.time())
|
|
1021
|
+
_save_record(layout, record)
|
|
1022
|
+
rc, out, err = throttle_rc(layout, 'studio', 'enroll', server, '--label', label,
|
|
1023
|
+
'--registration-key-file', str(key_file), *new_key, timeout=120)
|
|
1024
|
+
if rc != 0:
|
|
1025
|
+
# Review round 6: what was answered is remembered beside the attempt, so `status` names the refusal
|
|
1026
|
+
# (`key-refused`) for the hour the timer skips this key instead of pointing at the deploy.
|
|
1027
|
+
record.update(key_failed_exit=rc if rc is not None else 'timeout',
|
|
1028
|
+
key_failed_code=_refusal_code(err or out) if rc in (2, 3) else 'throttle_rc_failed')
|
|
1029
|
+
_save_record(layout, record)
|
|
1030
|
+
raise _throttle_rc_failed('enroll', rc, out, err, EnrolmentRefused, typed_exits=(2, 3))
|
|
1031
|
+
plan.step(f'enrol at {server} with the registration key in {key_file} (`throttle-rc studio enroll`'
|
|
1032
|
+
+ (' --new-key: this machine measured the old host row revoked' if new_key else '')
|
|
1033
|
+
+ '; the key is read by throttle-rc, never by this script)', enrol)
|
|
1034
|
+
return True
|
|
1035
|
+
if key_id and key_recent:
|
|
1036
|
+
minutes = int((time.time() - record.get('key_tried_at', 0)) // 60)
|
|
1037
|
+
plan.say(f'registration key {key_file} was tried {minutes} min ago and refused; the timer tries it again '
|
|
1038
|
+
'after an hour, a manual run of this script tries it now')
|
|
1039
|
+
return False
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _units(layout, plan):
|
|
1043
|
+
"""Step 6: render every lanes unit for this checkout (services.py), then enable the Host and its retry timer."""
|
|
1044
|
+
repo = Path(__file__).resolve().parent.parent
|
|
1045
|
+
|
|
1046
|
+
def render():
|
|
1047
|
+
sys.path.insert(0, str(repo / 'install'))
|
|
1048
|
+
import services
|
|
1049
|
+
# The roots THIS run resolved (Layout.from_env) are recorded, so a later `./install.sh --server` from a
|
|
1050
|
+
# shell without them re-renders the studio units with the same ones (review round 4).
|
|
1051
|
+
xdg = {name: os.environ[name] for name in services.XDG_ROOTS if os.environ.get(name)}
|
|
1052
|
+
services.record_xdg(layout.state, xdg)
|
|
1053
|
+
# Every lanes unit is re-rendered here, so the opt-in economy poller's and push notifier's settings come from
|
|
1054
|
+
# this shell exactly as `./install.sh --server` takes them (install/services.py __main__): without them the
|
|
1055
|
+
# enable step would strip an operator's LANES_THROTTLE_*/LANES_NOTIFY_CONF from those two units.
|
|
1056
|
+
services.register(repo, layout.home, layout.share, layout.state, os.environ.get('LANES_WORKSPACES'),
|
|
1057
|
+
economy=services.economy_settings(os.environ), notify=services.notify_settings(os.environ),
|
|
1058
|
+
xdg=xdg, units_dir=manager_units_dir(layout))
|
|
1059
|
+
_check_systemctl(*systemctl('daemon-reload'), 'daemon-reload')
|
|
1060
|
+
_check_systemctl(*systemctl('enable', UNIT, ENROL_TIMER), f'enable {UNIT} {ENROL_TIMER}')
|
|
1061
|
+
_check_systemctl(*systemctl('start', ENROL_TIMER), f'start {ENROL_TIMER}')
|
|
1062
|
+
plan.step(f'render the lanes units (install/services.py), daemon-reload, enable {UNIT} and {ENROL_TIMER}, start '
|
|
1063
|
+
'the timer', render)
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _stale_refusal_only(rc, out):
|
|
1067
|
+
"""The `last_refusal` of a `studio host --check` document whose ONLY red is that recorded refusal (exit 3,
|
|
1068
|
+
`identity.ok` true, `ok` false); None for anything else — an unparsable answer is never read as clean."""
|
|
1069
|
+
if rc != 3:
|
|
1070
|
+
return None
|
|
1071
|
+
try:
|
|
1072
|
+
doc = json.loads(out)
|
|
1073
|
+
except ValueError:
|
|
1074
|
+
return None
|
|
1075
|
+
if not isinstance(doc, dict) or doc.get('ok') is not False:
|
|
1076
|
+
return None
|
|
1077
|
+
identity, refusal = doc.get('identity'), doc.get('last_refusal')
|
|
1078
|
+
if not (isinstance(identity, dict) and identity.get('ok') is True and isinstance(refusal, str) and refusal):
|
|
1079
|
+
return None
|
|
1080
|
+
return refusal
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _start(layout, options, record):
|
|
1084
|
+
"""Step 7: start — or restart, so a new config or binary is in force — a Host that is enrolled and checks clean.
|
|
1085
|
+
The retry never restarts a Host that already ran with this enrolment: its exit 3 is a refusal, not a crash."""
|
|
1086
|
+
credential_sha = readable_sha(layout.credential, 'credential_unreadable',
|
|
1087
|
+
'it must be owned by this account (0600)')
|
|
1088
|
+
active = unit_word('is-active', UNIT)
|
|
1089
|
+
if options['retry']:
|
|
1090
|
+
if active in ('active', 'activating'):
|
|
1091
|
+
return 0
|
|
1092
|
+
if credential_sha == record.get('started_credential_sha'):
|
|
1093
|
+
print(f'{UNIT} is {active} with the enrolment it was already started with; not restarting it '
|
|
1094
|
+
'(`lanes studio-host status`)')
|
|
1095
|
+
return 0
|
|
1096
|
+
rc, out, err = throttle_rc(layout, 'studio', 'host', '--check', '--config', str(layout.config), timeout=60)
|
|
1097
|
+
# Review round 6: whatever enrolment the recorded refusal was written under. The sha gate that stood here made a
|
|
1098
|
+
# receipt adopted under a RUNNING Host (a relabel re-emits different bytes; the active-unit return above leaves
|
|
1099
|
+
# `started_credential_sha` naming the old credential) turn the Host's own later refusal into `host_check_failed`
|
|
1100
|
+
# on every timer tick AND on the documented manual re-run. The identity checks clean, so the Host is started and
|
|
1101
|
+
# judges THIS enrolment; the timer starts it once per enrolment (the sha check above, recorded below).
|
|
1102
|
+
stale = _stale_refusal_only(rc, out)
|
|
1103
|
+
if rc != 0 and stale is None:
|
|
1104
|
+
raise Refusal('host_check_failed', f'`throttle-rc studio host --check` exit {rc}; {UNIT} was not started:\n'
|
|
1105
|
+
+ (out + err).strip()[-1500:], 3 if rc == 3 else 1)
|
|
1106
|
+
if stale is not None:
|
|
1107
|
+
# Review round 4: the Host this installer started with THIS enrolment stopped on a refusal (the owner's
|
|
1108
|
+
# tunnel cap, a build mismatch, three displacements) and left it in state.json; `--check` reds on that
|
|
1109
|
+
# record alone, and only a Host run rewrites it. The identity checks clean, so the Host is started and the
|
|
1110
|
+
# settle window below judges THIS run — a refusal that still holds exits 3 again and is reported typed.
|
|
1111
|
+
print(f'`studio host --check`: the enrolment checks clean; the only red is the refusal the last run of '
|
|
1112
|
+
f'{UNIT} recorded ({stale[:200]}); starting it — its first phase replaces that record')
|
|
1113
|
+
verb = 'restart' if active == 'active' else 'start'
|
|
1114
|
+
_check_systemctl(*systemctl(verb, UNIT), f'{verb} {UNIT}')
|
|
1115
|
+
record['started_credential_sha'] = credential_sha
|
|
1116
|
+
_save_record(layout, record)
|
|
1117
|
+
deadline = time.monotonic() + options['settle']
|
|
1118
|
+
while True:
|
|
1119
|
+
state = unit_word('is-active', UNIT)
|
|
1120
|
+
if state != 'active':
|
|
1121
|
+
raise Refusal('host_not_running', f'{UNIT} is {state} after {verb}: `journalctl --user -u {UNIT} -n 30` '
|
|
1122
|
+
'(exit 3 = a typed refusal from the Host)')
|
|
1123
|
+
remaining = deadline - time.monotonic()
|
|
1124
|
+
if remaining <= 0:
|
|
1125
|
+
break
|
|
1126
|
+
time.sleep(min(0.5, remaining))
|
|
1127
|
+
print(f'running: {UNIT} {verb}ed and still active after {options["settle"]:g} s')
|
|
1128
|
+
return 0
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def enable(argv, layout=None):
|
|
1132
|
+
"""`sh install/enable-studio-host.sh [options]` — see USAGE. Everything that can refuse statically (another Host
|
|
1133
|
+
unit, the hand-built unit, no binary, the config mode) refuses before anything is changed."""
|
|
1134
|
+
options = parse_enable_args(argv)
|
|
1135
|
+
layout = layout or Layout.from_env()
|
|
1136
|
+
if options['retry'] and not layout.config.exists():
|
|
1137
|
+
print(f'{layout.config} is absent: nothing to retry (run install/enable-studio-host.sh)')
|
|
1138
|
+
return 0
|
|
1139
|
+
plan = Plan(options['dry_run'], quiet=options['retry'])
|
|
1140
|
+
record = _record(layout)
|
|
1141
|
+
receipt = Path(options['receipt']).absolute() if options['receipt'] else receipt_path(layout, record)
|
|
1142
|
+
key_file = Path(options['key_file']).absolute() if options['key_file'] else key_path(layout, record)
|
|
1143
|
+
hand_built = _one_host_per_machine(layout, options, record)
|
|
1144
|
+
# Review round 4: a RUNNING hand-built Host on a machine with no credential.json (ceobox's shape: a human-session
|
|
1145
|
+
# Host from before §8.7 enrolment) is enrolled FIRST, with the same machine key, while it still runs; only then is
|
|
1146
|
+
# it checked, stopped and replaced.
|
|
1147
|
+
enrol_first = (hand_built is not None and not layout.credential.exists()
|
|
1148
|
+
and unit_word('is-active', HAND_BUILT_UNIT) in ('active', 'activating', 'reloading'))
|
|
1149
|
+
if enrol_first and not (receipt.exists() or key_file.exists()):
|
|
1150
|
+
# Review round 5: with nothing to enrol from, the machine's PUBLIC key is published FIRST — the one artifact
|
|
1151
|
+
# the deploy needs to adopt it (deploy/studio-adopt-host.sh reads it). Refusing before publishing left each
|
|
1152
|
+
# side waiting for the other. publish-key loads the existing machine key and never rewrites it, and the
|
|
1153
|
+
# hand-built Host, its unit and its config are not touched.
|
|
1154
|
+
_ensure_binary(layout, options, plan)
|
|
1155
|
+
plan.step(f"publish this machine's PUBLIC key to {layout.public_jwk} (`throttle-rc studio publish-key`; the "
|
|
1156
|
+
'existing machine key is loaded, never rewritten)', lambda: _publish(layout))
|
|
1157
|
+
published = 'would be published' if options['dry_run'] else 'is published'
|
|
1158
|
+
raise Refusal('hand_built_host_unenrolled', f'{HAND_BUILT_UNIT} is running, but this machine holds no '
|
|
1159
|
+
f'enrolment receipt ({layout.credential}): the hand-built Host authenticates another way (a '
|
|
1160
|
+
"login session), which the lanes unit never uses. This machine's PUBLIC key "
|
|
1161
|
+
f'{published} at {layout.public_jwk} (the machine key itself unchanged). Enrol first — deploy '
|
|
1162
|
+
f'with THROTTLE_RC_STUDIO_ADOPT_JWK={layout.public_jwk} THROTTLE_RC_STUDIO_ADOPT_OWNER=<the '
|
|
1163
|
+
f"owner's sign-in email> THROTTLE_RC_STUDIO_ADOPT_RECEIPT={receipt} (from where those paths "
|
|
1164
|
+
"are this machine's), or put the owner's registration key in "
|
|
1165
|
+
f'{key_file} (0600) — then re-run with --migrate-hand-built: the receipt is installed while '
|
|
1166
|
+
f'{HAND_BUILT_UNIT} still runs, and it is stopped only after the new Host checks clean. Nothing '
|
|
1167
|
+
'else was changed: the hand-built Host, its unit and its config are as they were.', 3)
|
|
1168
|
+
_ensure_binary(layout, options, plan)
|
|
1169
|
+
backup = _ensure_config(layout, options, plan)
|
|
1170
|
+
if not options['dry_run']:
|
|
1171
|
+
_remember_paths(layout, options, record)
|
|
1172
|
+
replaced = [] # set once the hand-built unit is saved, disabled and stopped
|
|
1173
|
+
|
|
1174
|
+
def give_back():
|
|
1175
|
+
"""The hand-built Host reads the same config: until it is stopped, ANY failure gives it back the file it ran
|
|
1176
|
+
with (review round 5: only an enrolment refusal and a failed check used to, so a failed publish-key, an
|
|
1177
|
+
unreadable receipt or key, or a systemctl failure left it on a `mode` key its older binary refuses)."""
|
|
1178
|
+
state = 'LEFT running and enabled' if enrol_first else 'not stopped or disabled'
|
|
1179
|
+
if backup is None or options['dry_run']:
|
|
1180
|
+
return f'{HAND_BUILT_UNIT} was {state}'
|
|
1181
|
+
kept = Path(backup).read_bytes()
|
|
1182
|
+
try:
|
|
1183
|
+
current = layout.config.read_bytes()
|
|
1184
|
+
except OSError:
|
|
1185
|
+
current = None
|
|
1186
|
+
if current != kept:
|
|
1187
|
+
atomic_write(layout.config, kept, 0o600)
|
|
1188
|
+
return f'{HAND_BUILT_UNIT} was {state}, {layout.config} was restored from {backup}'
|
|
1189
|
+
try:
|
|
1190
|
+
if enrol_first:
|
|
1191
|
+
_enrol(layout, options, plan, record, receipt, key_file)
|
|
1192
|
+
if not options['dry_run'] and not layout.credential.exists():
|
|
1193
|
+
raise Refusal('hand_built_host_unenrolled', 'nothing enrolled this machine (no new receipt or '
|
|
1194
|
+
'registration key was tried)', 3)
|
|
1195
|
+
if hand_built is not None:
|
|
1196
|
+
_migrate_hand_built(layout, plan, hand_built, record, replaced)
|
|
1197
|
+
except BaseException as failure:
|
|
1198
|
+
if hand_built is not None and not replaced:
|
|
1199
|
+
note = give_back()
|
|
1200
|
+
if isinstance(failure, Refusal):
|
|
1201
|
+
failure.sentence += f'\n{note}; nothing was replaced'
|
|
1202
|
+
else:
|
|
1203
|
+
print(f'{note}; nothing was replaced', file=sys.stderr)
|
|
1204
|
+
raise
|
|
1205
|
+
try:
|
|
1206
|
+
enrolled = _enrol(layout, options, plan, record, receipt, key_file)
|
|
1207
|
+
except EnrolmentRefused as refusal:
|
|
1208
|
+
# Review round 3: a refused (or failed) receipt/key on the FIRST run used to end it before the units were
|
|
1209
|
+
# rendered, so no Host unit and no enrol timer existed while every surface promised the timer would retry.
|
|
1210
|
+
# The machine is left genuinely enabled and waiting; the refusal is still the run's answer (its exit code).
|
|
1211
|
+
if not options['retry']:
|
|
1212
|
+
_units(layout, plan)
|
|
1213
|
+
refusal.sentence += (f'\n{UNIT} is enabled and waiting for enrolment: {ENROL_TIMER} tries again '
|
|
1214
|
+
'when the receipt or key changes, the config changes, or the hour passes; a '
|
|
1215
|
+
'manual run of install/enable-studio-host.sh tries now')
|
|
1216
|
+
raise
|
|
1217
|
+
if not options['retry']:
|
|
1218
|
+
_units(layout, plan)
|
|
1219
|
+
if options['dry_run']:
|
|
1220
|
+
print(f'would: run `studio host --check`, then start (or restart) {UNIT}' if enrolled
|
|
1221
|
+
else f'would: leave {UNIT} enabled and waiting for enrolment')
|
|
1222
|
+
return 0
|
|
1223
|
+
# Review round 6: the enrolment `_enrol` measured, never "a credential.json exists" — a dead receipt beside a new
|
|
1224
|
+
# key (measured revoked, waiting for the deploy) is waiting, and is not started or checked.
|
|
1225
|
+
if not enrolled or not layout.credential.exists():
|
|
1226
|
+
print(f'waiting for enrolment: {UNIT} is enabled and exits 3 until this machine is enrolled.\n'
|
|
1227
|
+
f' deploy-provisioned: THROTTLE_RC_STUDIO_ADOPT_JWK={layout.public_jwk}, '
|
|
1228
|
+
f'THROTTLE_RC_STUDIO_ADOPT_RECEIPT={receipt}; {ENROL_TIMER} adopts the receipt within 5 minutes\n'
|
|
1229
|
+
f" or: put the owner's registration key in {key_file} (0600) and re-run this"
|
|
1230
|
+
if not options['retry'] else 'waiting for enrolment: no new receipt or registration key')
|
|
1231
|
+
return 0
|
|
1232
|
+
return _start(layout, options, record)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
# ------------------------------------------------------------------------------------------- re-key
|
|
1236
|
+
|
|
1237
|
+
REKEY_FORGET = ('receipt_adopted_sha', 'receipt_adopted_at', 'receipt_refused_sha', 'receipt_refused_config_sha',
|
|
1238
|
+
'receipt_refused_at', 'receipt_refused_code', 'key_tried_stat', LEGACY_KEY_DIGEST, 'key_tried_at',
|
|
1239
|
+
'started_credential_sha')
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def re_key(argv, layout=None):
|
|
1243
|
+
"""`lanes studio-host re-key [--yes] [--dry-run]` — the documented remedy after the owner revokes this machine
|
|
1244
|
+
(design §8.10 C4, review round 1). A revoked key is never adopted again, so the machine needs a NEW key: stop the
|
|
1245
|
+
Host, remove the machine key, the dead credential, the published key, the old receipt and the Host's refused
|
|
1246
|
+
state, forget the enrolment record's receipt/key memory (the hand-built migration record stays), then publish the
|
|
1247
|
+
new PUBLIC key. The next deploy adopts it and the enrol timer installs the receipt; or put a registration key in
|
|
1248
|
+
place and run install/enable-studio-host.sh. Without --yes it prints the plan and changes nothing (exit 2)."""
|
|
1249
|
+
unknown = [a for a in argv if a not in ('--yes', '--dry-run')]
|
|
1250
|
+
if unknown:
|
|
1251
|
+
raise Refusal('usage', 'usage: lanes studio-host re-key [--yes] [--dry-run]', 2)
|
|
1252
|
+
layout = layout or Layout.from_env()
|
|
1253
|
+
dry_run = '--dry-run' in argv or '--yes' not in argv
|
|
1254
|
+
plan = Plan(dry_run)
|
|
1255
|
+
word, detail, _pin = verify_installed(layout)
|
|
1256
|
+
if word != 'verified':
|
|
1257
|
+
raise Refusal('binary_mismatch' if word == 'mismatch' else 'no_binary', detail)
|
|
1258
|
+
active = unit_word('is-active', UNIT)
|
|
1259
|
+
if active == 'no-answer':
|
|
1260
|
+
# Review round 2: an unanswered manager is not "stopped" — deleting the key under a Host that may be running
|
|
1261
|
+
# is refused, never guessed.
|
|
1262
|
+
raise Refusal('manager_unreachable', f'the user manager did not answer `systemctl --user is-active {UNIT}`, '
|
|
1263
|
+
'so whether the Host runs is unknown and its key is not removed under it. Run this from a '
|
|
1264
|
+
'login of this account (XDG_RUNTIME_DIR set), or stop the Host first.')
|
|
1265
|
+
# Review round 5: the key is shared by EVERY Host unit of this account, not only the lanes one. The hand-built
|
|
1266
|
+
# sharing Host (ceobox) and `throttle-rc studio service`'s own unit run with this very machine key; removing it
|
|
1267
|
+
# under either makes its next proof fail. Refused while either may be running — the manager is asked, never
|
|
1268
|
+
# assumed.
|
|
1269
|
+
for other, remedy in ((HAND_BUILT_UNIT, 'migrate it first (sh install/enable-studio-host.sh --migrate-hand-built '
|
|
1270
|
+
f'— that keeps the key), or stop and disable it (systemctl --user disable --now {HAND_BUILT_UNIT})'),
|
|
1271
|
+
(SUPERVISOR_UNIT, 'remove it first (throttle-rc studio service uninstall --yes)')):
|
|
1272
|
+
state = unit_word('is-active', other)
|
|
1273
|
+
if state in ('active', 'activating', 'reloading', 'deactivating', 'no-answer'):
|
|
1274
|
+
raise Refusal('another_host_running', f'{other} is {state}: it runs a Host with this machine key '
|
|
1275
|
+
f'({layout.machine_key}), so the key is not removed under it. Nothing was changed; {remedy}, '
|
|
1276
|
+
'then re-run `lanes studio-host re-key`.')
|
|
1277
|
+
remembered = receipt_path(layout, _record(layout))
|
|
1278
|
+
# Review round 6: the machine key is removed LAST, and every path is checked removable BEFORE anything changes.
|
|
1279
|
+
# It used to go first, so a remembered --receipt in a directory this account may not write (the deploying
|
|
1280
|
+
# account's — the very place RECEIPT_UNREADABLE_REMEDY points at) ended the run in a PermissionError traceback
|
|
1281
|
+
# with the key already destroyed, the record unchanged and no new key published; every re-run failed the same way.
|
|
1282
|
+
doomed = [path for path in dict.fromkeys((layout.credential, layout.public_jwk, layout.receipt, remembered,
|
|
1283
|
+
layout.host_state, layout.machine_key))
|
|
1284
|
+
if path.exists() or path.is_symlink()]
|
|
1285
|
+
blocked = [path for path in doomed if not os.access(path.parent, os.W_OK | os.X_OK)]
|
|
1286
|
+
if blocked:
|
|
1287
|
+
detail = '; '.join(f'{path} (its directory: {unreadable_detail(path.parent, PermissionError(errno.EACCES, os.strerror(errno.EACCES)))})'
|
|
1288
|
+
for path in blocked)
|
|
1289
|
+
raise Refusal('rekey_path_unremovable', f'this account cannot remove {detail}. Nothing was changed: give it '
|
|
1290
|
+
'write access to that directory, or remove the file as its owner, then re-run '
|
|
1291
|
+
'`lanes studio-host re-key`.')
|
|
1292
|
+
if active in ('active', 'activating', 'reloading'):
|
|
1293
|
+
plan.step(f'stop {UNIT} (it is {active})', lambda: _check_systemctl(*systemctl('stop', UNIT), f'stop {UNIT}'))
|
|
1294
|
+
|
|
1295
|
+
def remove():
|
|
1296
|
+
for path in doomed:
|
|
1297
|
+
try:
|
|
1298
|
+
path.unlink()
|
|
1299
|
+
except FileNotFoundError:
|
|
1300
|
+
continue
|
|
1301
|
+
except OSError as error:
|
|
1302
|
+
raise Refusal('rekey_path_unremovable', f'{path}: {unreadable_detail(path, error)}. Removed so far: '
|
|
1303
|
+
+ (', '.join(str(p) for p in doomed[:doomed.index(path)]) or 'nothing')
|
|
1304
|
+
+ f'; the machine key {layout.machine_key} is removed last and was '
|
|
1305
|
+
+ ('not touched' if path != layout.machine_key else 'not removed')
|
|
1306
|
+
+ '. Fix the permission, then re-run `lanes studio-host re-key --yes`.') from error
|
|
1307
|
+
record = _record(layout)
|
|
1308
|
+
for key in REKEY_FORGET:
|
|
1309
|
+
record.pop(key, None)
|
|
1310
|
+
record['rekeyed_at'] = now_utc()
|
|
1311
|
+
_save_record(layout, record)
|
|
1312
|
+
plan.step('remove ' + (', '.join(str(p) for p in doomed) or 'nothing (no key or enrolment on disk)')
|
|
1313
|
+
+ f' and forget the receipt/key memory in {layout.record}', remove)
|
|
1314
|
+
|
|
1315
|
+
plan.step(f"publish the NEW machine's PUBLIC key to {layout.public_jwk} (`throttle-rc studio publish-key`)",
|
|
1316
|
+
lambda: _publish(layout))
|
|
1317
|
+
if '--yes' not in argv:
|
|
1318
|
+
print('nothing was changed: re-run with --yes (the old key cannot be restored; any throttle-rc session on '
|
|
1319
|
+
'this account bound to it must log in again)')
|
|
1320
|
+
return 2
|
|
1321
|
+
if dry_run:
|
|
1322
|
+
return 0
|
|
1323
|
+
print(f'next: deploy again (THROTTLE_RC_STUDIO_ADOPT_JWK={layout.public_jwk}); {ENROL_TIMER} installs the new '
|
|
1324
|
+
f"receipt within 5 minutes. Or put the owner's registration key in {layout.registration_key} (0600) and run "
|
|
1325
|
+
'sh install/enable-studio-host.sh')
|
|
1326
|
+
return 0
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
# ------------------------------------------------------------------------------------------- status
|
|
1330
|
+
|
|
1331
|
+
def unit_contract_missing(text):
|
|
1332
|
+
return [line for line in UNIT_CONTRACT if line not in text]
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def status_facts(layout, check=False):
|
|
1336
|
+
word, detail, pin = verify_installed(layout)
|
|
1337
|
+
facts = {'binary': {'state': word, 'detail': detail, 'path': str(layout.binary),
|
|
1338
|
+
'version': (pin or {}).get('version'), 'source': (pin or {}).get('source')}}
|
|
1339
|
+
try:
|
|
1340
|
+
facts['lock_pin'] = getattr(parse_lock(lock_path().read_text()).get(BINARY), 'version', None)
|
|
1341
|
+
except (OSError, Refusal) as error:
|
|
1342
|
+
facts['lock_pin'] = f'unreadable: {error}'
|
|
1343
|
+
config = {'path': str(layout.config), 'present': layout.config.exists()}
|
|
1344
|
+
if config['present']:
|
|
1345
|
+
try:
|
|
1346
|
+
parsed = config_facts(read_config_text(layout))
|
|
1347
|
+
except Refusal as refusal: # review round 5: said, never a traceback
|
|
1348
|
+
config.update(readable=False, detail=refusal.sentence)
|
|
1349
|
+
else:
|
|
1350
|
+
config.update(readable=True, mode=parsed['mode'], server=parsed.get('server'),
|
|
1351
|
+
machine_id=parsed.get('machine_id'),
|
|
1352
|
+
sharing_state_dir=parsed['inventory'].get('sharing_state_dir'))
|
|
1353
|
+
facts['config'] = config
|
|
1354
|
+
credential = load_credential(layout)
|
|
1355
|
+
record = _record(layout)
|
|
1356
|
+
receipt, key_file = receipt_path(layout, record), key_path(layout, record)
|
|
1357
|
+
receipt_readable = is_readable(receipt) if receipt.exists() else None
|
|
1358
|
+
# Review round 3: the timer's refusal memory, surfaced. A receipt on disk whose bytes the installer recorded as
|
|
1359
|
+
# refused is NOT "waiting for the deploy": the deploy did its part and the Host side refused it.
|
|
1360
|
+
refused = None
|
|
1361
|
+
if receipt_readable and record.get('receipt_refused_sha'):
|
|
1362
|
+
try:
|
|
1363
|
+
on_disk = sha256_file(receipt)
|
|
1364
|
+
except OSError:
|
|
1365
|
+
on_disk = None
|
|
1366
|
+
if on_disk == record['receipt_refused_sha']:
|
|
1367
|
+
config_now = None
|
|
1368
|
+
if layout.config.exists():
|
|
1369
|
+
try:
|
|
1370
|
+
config_now = sha256_file(layout.config)
|
|
1371
|
+
except OSError:
|
|
1372
|
+
config_now = None
|
|
1373
|
+
at = record.get('receipt_refused_at') or 0
|
|
1374
|
+
refused = {'code': record.get('receipt_refused_code', 'unknown'),
|
|
1375
|
+
'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(at)) if at else None,
|
|
1376
|
+
'config_changed_since': config_now != record.get('receipt_refused_config_sha'),
|
|
1377
|
+
'retry_after_s': max(0, int(at + RETRY_KEY_AFTER - time.time())) if at else 0}
|
|
1378
|
+
# Review round 6: the registration key's refusal, surfaced as the receipt's is — while the timer's memory of it
|
|
1379
|
+
# holds (the same file, within the hour). Its stat identity only: never opened.
|
|
1380
|
+
key_refused = None
|
|
1381
|
+
if record.get('key_failed_exit') is not None and key_file.exists() and os.access(key_file, os.R_OK):
|
|
1382
|
+
try:
|
|
1383
|
+
same_key = key_fingerprint(key_file, '') == record.get('key_tried_stat')
|
|
1384
|
+
except (Refusal, OSError):
|
|
1385
|
+
same_key = False
|
|
1386
|
+
at = record.get('key_tried_at') or 0
|
|
1387
|
+
if same_key and at:
|
|
1388
|
+
key_refused = {'code': record.get('key_failed_code') or 'unknown', 'exit': record['key_failed_exit'],
|
|
1389
|
+
'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(at)),
|
|
1390
|
+
'retry_after_s': max(0, int(at + RETRY_KEY_AFTER - time.time()))}
|
|
1391
|
+
binding = key_binding(layout, credential) if credential is not None else None
|
|
1392
|
+
server = config.get('server') if config.get('readable') else None
|
|
1393
|
+
# Review rounds 4 and 6: the predicate `_enrol` uses — a credential whose machine key is gone, or is NOT the key
|
|
1394
|
+
# this machine published, is not an enrolment.
|
|
1395
|
+
facts['enrolment'] = {
|
|
1396
|
+
'enrolled': credential is not None and layout.machine_key.exists() and binding is not False,
|
|
1397
|
+
'machine_key_present': layout.machine_key.exists(),
|
|
1398
|
+
'key_binding': {True: 'verified', False: 'another key', None: 'unchecked'}[binding],
|
|
1399
|
+
'measured_revoked': measured_revoked(layout, server) if credential is not None else None,
|
|
1400
|
+
'via': (credential or {}).get('via', 'unrecorded') if credential is not None else None,
|
|
1401
|
+
'machine_id': (credential or {}).get('machine_id'), 'owner': (credential or {}).get('owner_user_word_id'),
|
|
1402
|
+
'host_id': (credential or {}).get('host_id'),
|
|
1403
|
+
'public_key': str(layout.public_jwk) if layout.public_jwk.exists() else None,
|
|
1404
|
+
'receipt': str(receipt) if receipt.exists() else None,
|
|
1405
|
+
'receipt_path': str(receipt),
|
|
1406
|
+
'receipt_readable': receipt_readable,
|
|
1407
|
+
'receipt_adopted_at': record.get('receipt_adopted_at'),
|
|
1408
|
+
'receipt_refused': refused,
|
|
1409
|
+
'registration_key_file': str(key_file) if key_file.exists() else None,
|
|
1410
|
+
# access(2), never an open (review round 5: this module does not open the registration key)
|
|
1411
|
+
'registration_key_readable': os.access(key_file, os.R_OK) if key_file.exists() else None,
|
|
1412
|
+
'registration_key_tried_at': (time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(record['key_tried_at']))
|
|
1413
|
+
if record.get('key_tried_at') else None),
|
|
1414
|
+
'registration_key_refused': key_refused,
|
|
1415
|
+
}
|
|
1416
|
+
installed = manager_units_dir(layout) / UNIT
|
|
1417
|
+
facts['unit'] = {'file': installed.exists(), 'path': str(installed),
|
|
1418
|
+
'contract_missing': unit_contract_missing(installed.read_text()) if installed.exists() else None,
|
|
1419
|
+
'enabled': unit_word('is-enabled', UNIT), 'active': unit_word('is-active', UNIT),
|
|
1420
|
+
'enrol_timer': unit_word('is-active', ENROL_TIMER)}
|
|
1421
|
+
facts['conflicts'] = {SUPERVISOR_UNIT: unit_word('is-active', SUPERVISOR_UNIT),
|
|
1422
|
+
HAND_BUILT_UNIT: unit_word('is-active', HAND_BUILT_UNIT)}
|
|
1423
|
+
facts['state'], facts['next'] = classify(facts)
|
|
1424
|
+
if check and word == 'verified' and config['present'] and config.get('readable'):
|
|
1425
|
+
rc, out, err = throttle_rc(layout, 'studio', 'host', '--check', '--config', str(layout.config), timeout=60)
|
|
1426
|
+
facts['check'] = {'exit': rc, 'output': (out + err).strip()[-4000:]}
|
|
1427
|
+
# Review round 2: `--check` binds the exit. A running unit whose own check refuses is not "running".
|
|
1428
|
+
if facts['state'] == 'running' and rc != 0:
|
|
1429
|
+
facts['state'] = 'running-check-failed'
|
|
1430
|
+
facts['next'] = (f'the unit runs but `throttle-rc studio host --check` exit {rc}: read its output below '
|
|
1431
|
+
f'and journalctl --user -u {UNIT} -n 30')
|
|
1432
|
+
return facts
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
ENABLED_WORDS = ('enabled', 'enabled-runtime', 'linked', 'linked-runtime', 'alias')
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def classify(facts):
|
|
1439
|
+
"""-> (state word, next step). Words: running | running-unenrolled | waiting-for-enrolment | receipt-refused |
|
|
1440
|
+
key-refused | credential-for-gone-key | receipt-unreadable | key-unreadable | config-unreadable | not-enabled |
|
|
1441
|
+
stopped | failed | conflict | not-installed | unknown (and,
|
|
1442
|
+
with --check, running-check-failed, set by status_facts). `unknown` when the manager did not answer: never
|
|
1443
|
+
guessed. Only `running` (active AND enrolled, and with --check its check exit 0) exits 0."""
|
|
1444
|
+
unit, binary = facts['unit'], facts['binary']
|
|
1445
|
+
if facts['config'].get('readable') is False:
|
|
1446
|
+
return 'config-unreadable', f"{facts['config']['detail']}; then sh install/enable-studio-host.sh"
|
|
1447
|
+
if 'active' in facts['conflicts'].values():
|
|
1448
|
+
other = next(name for name, state in facts['conflicts'].items() if state == 'active')
|
|
1449
|
+
return 'conflict', f'{other} is running a Host on this machine: see docs/SHARING.md "Migrating the hand-built unit"'
|
|
1450
|
+
if binary['state'] != 'verified' or not unit['file'] or not facts['config']['present']:
|
|
1451
|
+
return 'not-installed', 'sh install/enable-studio-host.sh --server URL [--from PATH]'
|
|
1452
|
+
if unit['active'] == 'no-answer':
|
|
1453
|
+
return 'unknown', 'the user manager did not answer: systemctl --user status lanes-studio-host.service'
|
|
1454
|
+
enrolled = facts['enrolment']['enrolled']
|
|
1455
|
+
if unit['active'] == 'active':
|
|
1456
|
+
return ('running', '') if enrolled else ('running-unenrolled',
|
|
1457
|
+
'the Host runs without an enrolment receipt: check it')
|
|
1458
|
+
if not enrolled and facts['enrolment'].get('receipt_readable') is False:
|
|
1459
|
+
return 'receipt-unreadable', (f"{facts['enrolment']['receipt']} exists but this account cannot read it, so "
|
|
1460
|
+
f'{ENROL_TIMER} cannot install it: {RECEIPT_UNREADABLE_REMEDY}')
|
|
1461
|
+
if not enrolled and facts['enrolment'].get('registration_key_readable') is False:
|
|
1462
|
+
return 'key-unreadable', (f"{facts['enrolment']['registration_key_file']} exists but this account cannot read "
|
|
1463
|
+
f'it: {KEY_UNREADABLE_REMEDY}')
|
|
1464
|
+
# Review round 3: "waiting" is said only of a machine whose waiting is real — the Host unit enabled AND the enrol
|
|
1465
|
+
# timer active. Otherwise nothing will retry (a refused first enable used to leave neither installed).
|
|
1466
|
+
if unit['enabled'] == 'no-answer' or unit['enrol_timer'] == 'no-answer':
|
|
1467
|
+
return 'unknown', 'the user manager did not answer: systemctl --user status lanes-studio-host.service'
|
|
1468
|
+
timer_waits = unit['enabled'] in ENABLED_WORDS and unit['enrol_timer'] in ('active', 'activating')
|
|
1469
|
+
refused = facts['enrolment'].get('receipt_refused')
|
|
1470
|
+
# Review round 4: a refused receipt on disk is named whether or not an (older) credential.json exists — an
|
|
1471
|
+
# enrolled-but-stopped machine beside a refused NEW receipt is not merely `stopped`.
|
|
1472
|
+
if refused:
|
|
1473
|
+
if not timer_waits:
|
|
1474
|
+
when = 'nothing retries it: the units are not enabled'
|
|
1475
|
+
elif refused['config_changed_since']:
|
|
1476
|
+
when = f'{ENROL_TIMER} tries it again on its next tick (the config changed since)'
|
|
1477
|
+
else:
|
|
1478
|
+
when = (f"{ENROL_TIMER} tries it again in {refused['retry_after_s'] // 60} min or when "
|
|
1479
|
+
'studio-host.toml changes')
|
|
1480
|
+
return 'receipt-refused', (f"adopt-receipt refused {facts['enrolment']['receipt']} ({refused['code']}) at "
|
|
1481
|
+
f"{refused['at']}; {when}. receipt_server_mismatch: make the config's `server` the "
|
|
1482
|
+
"deployment's URL; receipt_key_mismatch: the deploy adopted another key — redeploy "
|
|
1483
|
+
'with this public key; then run sh install/enable-studio-host.sh to try it now')
|
|
1484
|
+
enrolment = facts['enrolment']
|
|
1485
|
+
if not enrolled and enrolment.get('via') is not None and enrolment.get('measured_revoked') is False:
|
|
1486
|
+
# Review round 6: a receipt whose key is gone (or is another key) for a row this machine has NOT measured
|
|
1487
|
+
# revoked — `_enrol` refuses to enrol a second machine beside it.
|
|
1488
|
+
host = enrolment.get('host_id') or 'unrecorded'
|
|
1489
|
+
return 'credential-for-gone-key', (f"the receipt on disk (host {host}, machine {enrolment['machine_id']}) is "
|
|
1490
|
+
"not for the machine key on disk, and this machine has not measured that "
|
|
1491
|
+
f"row revoked: revoke host {host} (console, or `throttle-rc studio revoke "
|
|
1492
|
+
f"--id {host}`), then lanes studio-host re-key --yes")
|
|
1493
|
+
key_refused = enrolment.get('registration_key_refused')
|
|
1494
|
+
if not enrolled and key_refused:
|
|
1495
|
+
# Review round 6: the key route's refusal, named as the receipt's is (it used to say "waiting" and point at
|
|
1496
|
+
# the deploy while the timer skipped the key for the hour).
|
|
1497
|
+
when = (f"{ENROL_TIMER} tries it again in {key_refused['retry_after_s'] // 60} min or when the file changes"
|
|
1498
|
+
if timer_waits else 'nothing retries it: the units are not enabled')
|
|
1499
|
+
return 'key-refused', (f"`throttle-rc studio enroll` refused {enrolment['registration_key_file']} "
|
|
1500
|
+
f"({key_refused['code']}, exit {key_refused['exit']}) at {key_refused['at']}; {when}; "
|
|
1501
|
+
f'a manual run of sh install/enable-studio-host.sh tries it now '
|
|
1502
|
+
f'(journalctl --user -u {ENROL_SERVICE} -n 20 has its line)')
|
|
1503
|
+
if not enrolled and not timer_waits:
|
|
1504
|
+
return 'not-enabled', (f"{UNIT} is {unit['enabled']} and {ENROL_TIMER} is {unit['enrol_timer']}, so nothing "
|
|
1505
|
+
'retries the enrolment: run sh install/enable-studio-host.sh')
|
|
1506
|
+
if not enrolled:
|
|
1507
|
+
return 'waiting-for-enrolment', ('the deploy adopts the public key and writes the receipt; '
|
|
1508
|
+
f'{ENROL_TIMER} installs it (or: registration key file, re-run the enable step)')
|
|
1509
|
+
if unit['active'] == 'failed':
|
|
1510
|
+
return 'failed', f'journalctl --user -u {UNIT} -n 30, then systemctl --user reset-failed {UNIT}'
|
|
1511
|
+
return 'stopped', f'journalctl --user -u {UNIT} -n 30 (exit 3 = typed refusal), then systemctl --user start {UNIT}'
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def print_status(facts):
|
|
1515
|
+
print(f"state: {facts['state']}" + (f" — next: {facts['next']}" if facts['next'] else ''))
|
|
1516
|
+
binary = facts['binary']
|
|
1517
|
+
print(f"binary: {binary['state']} — {binary['detail']}; holders.lock pin: {facts['lock_pin'] or 'none yet'}")
|
|
1518
|
+
config = facts['config']
|
|
1519
|
+
if config['present'] and config.get('readable') is False:
|
|
1520
|
+
print(f"config: {config['path']} — unreadable by this account")
|
|
1521
|
+
else:
|
|
1522
|
+
print(f"config: {config['path']}" + (f" — mode {config.get('mode')}, server {config.get('server')}, machine "
|
|
1523
|
+
f"{config.get('machine_id')}" if config['present'] else ' — absent'))
|
|
1524
|
+
enrolment = facts['enrolment']
|
|
1525
|
+
if enrolment['enrolled']:
|
|
1526
|
+
print(f"enrolment: via {enrolment['via']}, machine {enrolment['machine_id']}, owner {enrolment['owner']}")
|
|
1527
|
+
else:
|
|
1528
|
+
if enrolment['via'] is not None: # a credential.json whose machine key is gone, or is another key
|
|
1529
|
+
which = 'gone' if not enrolment['machine_key_present'] else 'not the key on disk'
|
|
1530
|
+
revoked = (' (this machine measured that host row revoked)' if enrolment.get('measured_revoked')
|
|
1531
|
+
else ' (NOT measured revoked here)')
|
|
1532
|
+
print(f"enrolment: the receipt on disk (machine {enrolment['machine_id']}) is for a machine key that is "
|
|
1533
|
+
f'{which}{revoked}; it does not enrol this machine')
|
|
1534
|
+
key_word = 'none'
|
|
1535
|
+
if enrolment['registration_key_file']:
|
|
1536
|
+
refused = enrolment.get('registration_key_refused')
|
|
1537
|
+
key_word = 'present' + (f" — tried {refused['at']}, refused {refused['code']} (exit {refused['exit']})"
|
|
1538
|
+
if refused else '')
|
|
1539
|
+
print('enrolment: none — public key ' + (enrolment['public_key'] or 'not published')
|
|
1540
|
+
+ '; receipt ' + (enrolment['receipt'] or f"none (watching {enrolment['receipt_path']})")
|
|
1541
|
+
+ '; registration key file ' + key_word)
|
|
1542
|
+
if enrolment.get('receipt_refused'):
|
|
1543
|
+
refused = enrolment['receipt_refused']
|
|
1544
|
+
print(f"receipt refused: {refused['code']} at {refused['at']} (journalctl --user -u {ENROL_SERVICE} -n 20)")
|
|
1545
|
+
unit = facts['unit']
|
|
1546
|
+
print(f"unit: {UNIT} file {'present' if unit['file'] else 'absent'}, {unit['enabled']}, {unit['active']}; "
|
|
1547
|
+
f"{ENROL_TIMER} {unit['enrol_timer']}")
|
|
1548
|
+
if unit['contract_missing']:
|
|
1549
|
+
print(f" the installed unit lacks: {'; '.join(unit['contract_missing'])} — re-run ./install.sh --server")
|
|
1550
|
+
print('other Host units: ' + ', '.join(f'{name} {state}' for name, state in facts['conflicts'].items()))
|
|
1551
|
+
if 'check' in facts:
|
|
1552
|
+
print(f"studio host --check: exit {facts['check']['exit']}")
|
|
1553
|
+
print(facts['check']['output'])
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def run_cli(args):
|
|
1557
|
+
"""`lanes studio-host <verb>` (bin/lanes hands over its arguments; `main` types the refusals)."""
|
|
1558
|
+
verb, rest = (args[0], args[1:]) if args else ('status', [])
|
|
1559
|
+
layout = Layout.from_env()
|
|
1560
|
+
if verb == 'status':
|
|
1561
|
+
unknown = [a for a in rest if a not in ('--json', '--check')]
|
|
1562
|
+
if unknown:
|
|
1563
|
+
raise Refusal('usage', 'usage: lanes studio-host status [--json] [--check]', 2)
|
|
1564
|
+
facts = status_facts(layout, check='--check' in rest)
|
|
1565
|
+
if '--json' in rest:
|
|
1566
|
+
print(json.dumps({'schema': 'lanes.studio-host-status', 'version': 1, **facts}, indent=1, sort_keys=True))
|
|
1567
|
+
else:
|
|
1568
|
+
print_status(facts)
|
|
1569
|
+
return 0 if facts['state'] == 'running' else 1
|
|
1570
|
+
if verb == 'install':
|
|
1571
|
+
source = expected = None
|
|
1572
|
+
items = list(rest)
|
|
1573
|
+
while items:
|
|
1574
|
+
item = items.pop(0)
|
|
1575
|
+
if item in ('--from', '--sha256') and items:
|
|
1576
|
+
source, expected = (items.pop(0), expected) if item == '--from' else (source, items.pop(0))
|
|
1577
|
+
else:
|
|
1578
|
+
raise Refusal('usage', 'usage: lanes studio-host install [--from PATH [--sha256 HEX]]', 2)
|
|
1579
|
+
if expected and not source:
|
|
1580
|
+
raise Refusal('usage', '--sha256 goes with --from', 2)
|
|
1581
|
+
if source:
|
|
1582
|
+
record = install_local(layout, source, expected)
|
|
1583
|
+
else:
|
|
1584
|
+
pins = parse_lock(lock_path().read_text())
|
|
1585
|
+
if BINARY not in pins:
|
|
1586
|
+
raise Refusal('no_binary', f'holders.lock pins no {BINARY} release yet: use --from PATH')
|
|
1587
|
+
record = install_pinned(layout, pins[BINARY])
|
|
1588
|
+
print(f"selected {layout.binary} -> {BINARY}-{record['version']} (sha256 {record['sha256']}); nothing was "
|
|
1589
|
+
f'restarted: `systemctl --user restart {UNIT}` puts it in force')
|
|
1590
|
+
return 0
|
|
1591
|
+
if verb == 'enable':
|
|
1592
|
+
return enable(rest, layout)
|
|
1593
|
+
if verb == 're-key':
|
|
1594
|
+
return re_key(rest, layout)
|
|
1595
|
+
raise Refusal('usage', 'usage: lanes studio-host status [--json] [--check] | install [--from PATH [--sha256 HEX]]'
|
|
1596
|
+
' | re-key [--yes] [--dry-run]', 2)
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def main(argv):
|
|
1600
|
+
try:
|
|
1601
|
+
return run_cli(argv)
|
|
1602
|
+
except Refusal as refusal:
|
|
1603
|
+
print(f'refused {refusal.code}: {refusal.sentence}', file=sys.stderr)
|
|
1604
|
+
return refusal.exit_code
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
if __name__ == '__main__':
|
|
1608
|
+
sys.exit(main(sys.argv[1:]))
|