zettcode-gateway 0.3.4__py3-none-any.whl
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.
- zettcode/gateway/__init__.py +22 -0
- zettcode/gateway/app.py +454 -0
- zettcode/gateway/backend.py +368 -0
- zettcode/gateway/chat.py +142 -0
- zettcode/gateway/cli.py +495 -0
- zettcode/gateway/py.typed +0 -0
- zettcode/gateway/runfile.py +145 -0
- zettcode/gateway/service.py +1219 -0
- zettcode/gateway/settings.py +62 -0
- zettcode/gateway/static/app.css +1434 -0
- zettcode/gateway/static/app.js +3 -0
- zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
- zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +1 -0
- zettcode/gateway/static/chunks/render-G6G3TSB3.js +341 -0
- zettcode/gateway/static/icon.png +0 -0
- zettcode/gateway/static/index.html +23 -0
- zettcode/gateway/static/logo.svg +205 -0
- zettcode/gateway/static/manifest.webmanifest +11 -0
- zettcode_gateway-0.3.4.dist-info/METADATA +21 -0
- zettcode_gateway-0.3.4.dist-info/RECORD +40 -0
- zettcode_gateway-0.3.4.dist-info/WHEEL +4 -0
zettcode/gateway/cli.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""The ``zettcode gateway`` command: serve the page, or start, stop and restart it in the background.
|
|
2
|
+
|
|
3
|
+
Run through the ``zettcode`` console script, so ``zettcode gateway --help`` answers before anything heavy is
|
|
4
|
+
imported.
|
|
5
|
+
|
|
6
|
+
zettcode gateway # serve in this terminal until interrupted
|
|
7
|
+
zettcode gateway start [flags] # serve in the background and return
|
|
8
|
+
zettcode gateway stop # stop the one that is running
|
|
9
|
+
zettcode gateway restart [flags] # stop it, then start again with these settings (or the ones it had)
|
|
10
|
+
zettcode gateway serve [flags] # the same as the bare command
|
|
11
|
+
|
|
12
|
+
A gateway writes down the pid and settings it is serving with once it is a session (:mod:`.runfile`); ``stop`` and
|
|
13
|
+
``restart`` read that record, check the process is really a gateway before signalling it, and a restart with no flags
|
|
14
|
+
reuses the recorded settings, so a token or port is not lost by restarting.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import signal
|
|
24
|
+
import socket
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Literal
|
|
30
|
+
|
|
31
|
+
from zettcode import logging_setup
|
|
32
|
+
from zettcode.processes import command_line, is_module_command, still_there
|
|
33
|
+
from zettcode.version import __version__
|
|
34
|
+
|
|
35
|
+
from . import runfile
|
|
36
|
+
from .settings import DEFAULT_HOST, DEFAULT_PORT, TOKEN_VARIABLE, Settings, default_directory
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("zettcode.gateway")
|
|
39
|
+
|
|
40
|
+
LOOPBACK = ("127.0.0.1", "::1", "localhost")
|
|
41
|
+
|
|
42
|
+
#: The module the background gateway runs as, so the process can be recognised before it is stopped.
|
|
43
|
+
GATEWAY_MODULE = "zettcode.gateway.cli"
|
|
44
|
+
|
|
45
|
+
#: Seconds a gateway that was just started has to become a session and open its port.
|
|
46
|
+
START_WAIT = 12.0
|
|
47
|
+
|
|
48
|
+
#: Seconds a gateway is given to leave after it was asked, and after it was made to.
|
|
49
|
+
STOP_GRACE = 10.0
|
|
50
|
+
STOP_FORCE = 2.0
|
|
51
|
+
|
|
52
|
+
#: How often a port, a record or a process is looked at while waiting.
|
|
53
|
+
POLL = 0.2
|
|
54
|
+
|
|
55
|
+
EXIT_OK = 0
|
|
56
|
+
EXIT_FAILED = 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
60
|
+
"""Return the parser: the action, where to listen, who may sign in, and where agents may be started."""
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="zettcode gateway",
|
|
63
|
+
description=(
|
|
64
|
+
"A web page for the background agents on this machine: list the daemons and the sessions that are "
|
|
65
|
+
"running, start and stop daemons, and talk to any of them. It works from a phone as well. With no "
|
|
66
|
+
"action it serves in this terminal; start, stop and restart manage one in the background."
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"action",
|
|
71
|
+
nargs="?",
|
|
72
|
+
choices=("serve", "start", "stop", "restart"),
|
|
73
|
+
default="serve",
|
|
74
|
+
help="serve here (the default), or start, stop or restart a background gateway",
|
|
75
|
+
)
|
|
76
|
+
# The defaults are suppressed so that `restart` can tell which settings were given from the ones it should keep
|
|
77
|
+
# from the running gateway; the help text says what each default is.
|
|
78
|
+
parser.add_argument("--host", default=argparse.SUPPRESS, help=f"interface to listen on (default: {DEFAULT_HOST})")
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--port", type=int, default=argparse.SUPPRESS, help=f"port to listen on (default: {DEFAULT_PORT})"
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--token",
|
|
84
|
+
default=argparse.SUPPRESS,
|
|
85
|
+
help=(
|
|
86
|
+
f"what to type to sign in (default: ${TOKEN_VARIABLE}). With none there is no sign-in, which is only "
|
|
87
|
+
"allowed while listening on this machine alone"
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"-r",
|
|
92
|
+
"--root",
|
|
93
|
+
metavar="DIR",
|
|
94
|
+
action="append",
|
|
95
|
+
type=Path,
|
|
96
|
+
default=argparse.SUPPRESS,
|
|
97
|
+
help=(
|
|
98
|
+
"limit daemons to this directory and below (repeatable). By default any directory may be chosen; "
|
|
99
|
+
"with -r, nothing outside the roots can"
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--no-full",
|
|
104
|
+
action="store_true",
|
|
105
|
+
default=argparse.SUPPRESS,
|
|
106
|
+
help="do not let the page start daemons with the full shell level (no sandbox, no approval)",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--full",
|
|
110
|
+
dest="no_full",
|
|
111
|
+
action="store_false",
|
|
112
|
+
default=argparse.SUPPRESS,
|
|
113
|
+
help="let the page start daemons with the full shell level again, undoing a recorded --no-full",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--directory",
|
|
117
|
+
metavar="DIR",
|
|
118
|
+
type=Path,
|
|
119
|
+
default=argparse.SUPPRESS,
|
|
120
|
+
help="where the gateway keeps its own session and what the page remembers (default: ~/.zettcode/gateway)",
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--log-level",
|
|
124
|
+
choices=("critical", "error", "warning", "info", "debug", "trace"),
|
|
125
|
+
default=argparse.SUPPRESS,
|
|
126
|
+
help="uvicorn's log level (default: info)",
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument("--version", action="version", version=version_text())
|
|
129
|
+
return parser
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def version_text() -> str:
|
|
133
|
+
"""Return the line ``--version`` prints: the gateway and the agent are one package, so one version."""
|
|
134
|
+
return f"zettcode gateway {__version__}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def settings_from(argv: list[str] | None = None, *, base: Settings | None = None) -> Settings:
|
|
138
|
+
"""Return the settings the arguments name; ``--help`` exits here, and so does an unsafe combination.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
argv: The arguments to read; the process's own when ``None``.
|
|
142
|
+
base: Settings to keep where the arguments say nothing, which is how a restart keeps the host, port and
|
|
143
|
+
token the running gateway was started with. ``None`` uses the defaults.
|
|
144
|
+
"""
|
|
145
|
+
args = build_parser().parse_args(argv)
|
|
146
|
+
host = getattr(args, "host", None) or (base.host if base is not None else DEFAULT_HOST)
|
|
147
|
+
port = getattr(args, "port", None)
|
|
148
|
+
if port is None:
|
|
149
|
+
port = base.port if base is not None else DEFAULT_PORT
|
|
150
|
+
token = getattr(args, "token", None)
|
|
151
|
+
if token is None:
|
|
152
|
+
# A restart keeps the token the gateway was started with before the environment is consulted: a stale
|
|
153
|
+
# exported token must not silently change the sign-in of the page that is already open.
|
|
154
|
+
if base is not None:
|
|
155
|
+
token = base.token
|
|
156
|
+
else:
|
|
157
|
+
token = os.environ.get(TOKEN_VARIABLE) or ""
|
|
158
|
+
roots_given = getattr(args, "root", None)
|
|
159
|
+
if roots_given is None:
|
|
160
|
+
roots = base.roots if base is not None and base.roots else (Path(Path.home().anchor or "/"),)
|
|
161
|
+
else:
|
|
162
|
+
roots = tuple(root.expanduser().resolve() for root in roots_given)
|
|
163
|
+
no_full = getattr(args, "no_full", None)
|
|
164
|
+
if no_full is None:
|
|
165
|
+
allow_full = base.allow_full if base is not None else True
|
|
166
|
+
else:
|
|
167
|
+
allow_full = not no_full
|
|
168
|
+
directory_given = getattr(args, "directory", None)
|
|
169
|
+
if directory_given is not None:
|
|
170
|
+
directory = directory_given.expanduser()
|
|
171
|
+
else:
|
|
172
|
+
directory = base.directory if base is not None else default_directory()
|
|
173
|
+
log_level = getattr(args, "log_level", None) or (base.log_level if base is not None else "info")
|
|
174
|
+
|
|
175
|
+
if not 0 < port < 65536:
|
|
176
|
+
raise SystemExit(f"zettcode gateway: --port must be between 1 and 65535, not {port}")
|
|
177
|
+
missing = [str(root) for root in roots if not root.is_dir()]
|
|
178
|
+
if missing:
|
|
179
|
+
raise SystemExit(f"zettcode gateway: no such directory: {', '.join(missing)}")
|
|
180
|
+
if not token and host not in LOOPBACK:
|
|
181
|
+
raise SystemExit(
|
|
182
|
+
f"zettcode gateway: listening on {host} needs a token (--token or ${TOKEN_VARIABLE}): "
|
|
183
|
+
"without one anybody on the network could run commands as you"
|
|
184
|
+
)
|
|
185
|
+
return Settings(
|
|
186
|
+
token=token,
|
|
187
|
+
host=host,
|
|
188
|
+
port=port,
|
|
189
|
+
roots=roots,
|
|
190
|
+
allow_full=allow_full,
|
|
191
|
+
directory=directory,
|
|
192
|
+
log_level=log_level,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main(argv: list[str] | None = None) -> None:
|
|
197
|
+
"""Entry point of ``zettcode gateway``: serve, or start, stop, restart one in the background."""
|
|
198
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
199
|
+
args = build_parser().parse_args(raw)
|
|
200
|
+
action = args.action
|
|
201
|
+
if action == "stop":
|
|
202
|
+
raise SystemExit(stop(_directory_from(args)))
|
|
203
|
+
if action == "restart":
|
|
204
|
+
recorded = runfile.read(_directory_from(args))
|
|
205
|
+
base = recorded.settings if recorded is not None else None
|
|
206
|
+
raise SystemExit(restart(settings_from(raw, base=base)))
|
|
207
|
+
settings = settings_from(raw)
|
|
208
|
+
if action == "start":
|
|
209
|
+
raise SystemExit(start(settings))
|
|
210
|
+
serve(settings)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def serve(settings: Settings) -> None:
|
|
214
|
+
"""Serve in this terminal until interrupted. The record is written by the application once it is a session."""
|
|
215
|
+
from .app import create_app
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
import uvicorn
|
|
219
|
+
except ImportError as error: # pragma: no cover - the wheel declares it
|
|
220
|
+
raise SystemExit(f"zettcode gateway: uvicorn is not installed: {error}") from error
|
|
221
|
+
|
|
222
|
+
_say(settings)
|
|
223
|
+
logging_setup.setup("gateway")
|
|
224
|
+
logging_setup.rotate_text(runfile.log_path(settings.directory))
|
|
225
|
+
try:
|
|
226
|
+
uvicorn.run(create_app(settings), host=settings.host, port=settings.port, log_level=settings.log_level)
|
|
227
|
+
except (KeyboardInterrupt, SystemExit):
|
|
228
|
+
raise
|
|
229
|
+
except BaseException:
|
|
230
|
+
logger.exception("serve.failed", extra={"host": settings.host, "port": settings.port})
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def start(settings: Settings) -> int:
|
|
235
|
+
"""Start a gateway in the background unless one is serving, and say where it is."""
|
|
236
|
+
record = runfile.read(settings.directory)
|
|
237
|
+
if record is not None:
|
|
238
|
+
state = _state(record)
|
|
239
|
+
if state == "gateway":
|
|
240
|
+
_say(record.settings)
|
|
241
|
+
if settings != record.settings:
|
|
242
|
+
print("the settings given were not applied; stop the gateway first to change them")
|
|
243
|
+
print(f"the gateway is already running (pid {record.pid})")
|
|
244
|
+
return EXIT_OK
|
|
245
|
+
if state == "unknown":
|
|
246
|
+
print(
|
|
247
|
+
f"a process (pid {record.pid}) may be the gateway at {record.settings.url} but its command line "
|
|
248
|
+
f"could not be read; not starting another. Its record is {runfile.path(settings.directory)}; remove "
|
|
249
|
+
"it once nothing is serving there",
|
|
250
|
+
file=sys.stderr,
|
|
251
|
+
)
|
|
252
|
+
return EXIT_FAILED
|
|
253
|
+
runfile.clear(settings.directory)
|
|
254
|
+
try:
|
|
255
|
+
process = spawn(settings)
|
|
256
|
+
except OSError as error:
|
|
257
|
+
logger.exception("spawn.failed", extra={"host": settings.host, "port": settings.port})
|
|
258
|
+
print(f"the gateway could not be started: {error}", file=sys.stderr)
|
|
259
|
+
return EXIT_FAILED
|
|
260
|
+
if not wait_serving(settings, process):
|
|
261
|
+
print(f"the gateway did not start; the log is {runfile.log_path(settings.directory)}", file=sys.stderr)
|
|
262
|
+
runfile.clear(settings.directory, pid=process.pid)
|
|
263
|
+
return EXIT_FAILED
|
|
264
|
+
_say(settings)
|
|
265
|
+
print(f"the gateway is running in the background (pid {process.pid})")
|
|
266
|
+
return EXIT_OK
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def stop(directory: Path, *, quiet: bool = False) -> int:
|
|
270
|
+
"""Stop the gateway serving from ``directory``, and say what happened.
|
|
271
|
+
|
|
272
|
+
A record whose process is gone or is not a gateway any more is removed rather than acted on: a pinned process
|
|
273
|
+
can be reused, and ending the wrong one would be worse than not ending the right one. A process that exists but
|
|
274
|
+
whose command line cannot be read is neither: the record is kept and the command fails, because a gateway that
|
|
275
|
+
may be serving would otherwise be left with no way to stop it.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
directory: The gateway folder whose record is read.
|
|
279
|
+
quiet: Say nothing; a restart that reports the start it made does not also report the stop it made.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def say(text: str, *, err: bool = False, always: bool = False) -> None:
|
|
283
|
+
if quiet and not always:
|
|
284
|
+
return
|
|
285
|
+
print(text, file=sys.stderr if err else sys.stdout)
|
|
286
|
+
|
|
287
|
+
record = runfile.read(directory)
|
|
288
|
+
if record is None:
|
|
289
|
+
say("the gateway is not running")
|
|
290
|
+
return EXIT_OK
|
|
291
|
+
state = _state(record)
|
|
292
|
+
if state in ("gone", "other"):
|
|
293
|
+
runfile.clear(directory, pid=record.pid)
|
|
294
|
+
say("the gateway is not running")
|
|
295
|
+
return EXIT_OK
|
|
296
|
+
if state == "unknown":
|
|
297
|
+
say(
|
|
298
|
+
f"a process (pid {record.pid}) may be the gateway at {record.settings.url} but its command line could "
|
|
299
|
+
f"not be read; it was not stopped. Its record is {runfile.path(directory)}; remove it once nothing is "
|
|
300
|
+
"serving there",
|
|
301
|
+
err=True,
|
|
302
|
+
always=True,
|
|
303
|
+
)
|
|
304
|
+
return EXIT_FAILED
|
|
305
|
+
pid = record.pid
|
|
306
|
+
try:
|
|
307
|
+
os.kill(pid, signal.SIGTERM)
|
|
308
|
+
except OSError:
|
|
309
|
+
runfile.clear(directory, pid=pid)
|
|
310
|
+
say("the gateway is not running")
|
|
311
|
+
return EXIT_OK
|
|
312
|
+
if _wait_gone(pid, STOP_GRACE) or _force(pid, STOP_FORCE):
|
|
313
|
+
runfile.clear(directory, pid=pid)
|
|
314
|
+
say(f"stopped the gateway ({pid})")
|
|
315
|
+
return EXIT_OK
|
|
316
|
+
say(f"the gateway ({pid}) did not stop", err=True, always=True)
|
|
317
|
+
return EXIT_FAILED
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def restart(settings: Settings) -> int:
|
|
321
|
+
"""Stop the gateway serving from this directory if one is, then start one with ``settings``."""
|
|
322
|
+
stopped = stop(settings.directory, quiet=True)
|
|
323
|
+
if stopped != EXIT_OK:
|
|
324
|
+
return stopped
|
|
325
|
+
return start(settings)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def spawn(settings: Settings) -> subprocess.Popen[bytes]:
|
|
329
|
+
"""Start a gateway as a process of its own, detached, writing to the gateway's log; the token goes in its environment.
|
|
330
|
+
|
|
331
|
+
The sign-in token is not put on the command line: a background process's arguments are visible to the whole
|
|
332
|
+
machine, and the token is what keeps others out. The child inherits it through :data:`TOKEN_VARIABLE`, and it
|
|
333
|
+
writes its own record once it is serving.
|
|
334
|
+
"""
|
|
335
|
+
runfile.ensure_directory(settings.directory)
|
|
336
|
+
arguments = [
|
|
337
|
+
sys.executable,
|
|
338
|
+
"-m",
|
|
339
|
+
GATEWAY_MODULE,
|
|
340
|
+
"serve",
|
|
341
|
+
"--host",
|
|
342
|
+
settings.host,
|
|
343
|
+
"--port",
|
|
344
|
+
str(settings.port),
|
|
345
|
+
"--directory",
|
|
346
|
+
str(settings.directory),
|
|
347
|
+
"--log-level",
|
|
348
|
+
settings.log_level,
|
|
349
|
+
]
|
|
350
|
+
if not settings.allow_full:
|
|
351
|
+
arguments.append("--no-full")
|
|
352
|
+
for root in settings.roots:
|
|
353
|
+
arguments.extend(("--root", str(root)))
|
|
354
|
+
environment = {**os.environ, TOKEN_VARIABLE: settings.token}
|
|
355
|
+
options: dict[str, Any] = {
|
|
356
|
+
"stdin": subprocess.DEVNULL,
|
|
357
|
+
"cwd": str(settings.directory),
|
|
358
|
+
"close_fds": True,
|
|
359
|
+
"env": environment,
|
|
360
|
+
}
|
|
361
|
+
if os.name == "nt":
|
|
362
|
+
options["creationflags"] = (
|
|
363
|
+
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined,unused-ignore]
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
366
|
+
options["start_new_session"] = True
|
|
367
|
+
with open(runfile.log_path(settings.directory), "ab") as output:
|
|
368
|
+
return subprocess.Popen(arguments, stdout=output, stderr=output, **options) # noqa: S603
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def wait_serving(settings: Settings, process: subprocess.Popen[bytes]) -> bool:
|
|
372
|
+
"""Wait for the child to record itself and open its port; ``False`` when it exited or never got there."""
|
|
373
|
+
deadline = time.monotonic() + START_WAIT
|
|
374
|
+
while time.monotonic() < deadline:
|
|
375
|
+
if process.poll() is not None:
|
|
376
|
+
return False
|
|
377
|
+
record = runfile.read(settings.directory)
|
|
378
|
+
if record is not None and record.pid == process.pid and _connectable(settings):
|
|
379
|
+
# A port that accepted one connection may still be a server that fails on start-up; give it a moment.
|
|
380
|
+
time.sleep(0.2)
|
|
381
|
+
return process.poll() is None
|
|
382
|
+
time.sleep(POLL)
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _state(record: runfile.Running) -> Literal["gateway", "other", "unknown", "gone"]:
|
|
387
|
+
"""Return what the recorded pid is now: ``"gateway"``, ``"other"`` (reused), ``"unknown"`` (alive, unreadable) or ``"gone"``.
|
|
388
|
+
|
|
389
|
+
The two unclear answers are kept apart on purpose: a pid that exists but whose command line cannot be read may be
|
|
390
|
+
the gateway, and neither starting a second one nor ending it on a guess is right.
|
|
391
|
+
"""
|
|
392
|
+
text = command_line(record.pid)
|
|
393
|
+
if text is not None:
|
|
394
|
+
return "gateway" if _is_gateway(text) else "other"
|
|
395
|
+
return "unknown" if still_there(record.pid) else "gone"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _is_gateway(text: str) -> bool:
|
|
399
|
+
"""Return whether a command line is this gateway: ``python -m zettcode.gateway.cli``, or the console script."""
|
|
400
|
+
if is_module_command(text, GATEWAY_MODULE):
|
|
401
|
+
return True
|
|
402
|
+
# The console script: `python /…/bin/zettcode gateway …` on POSIX, `"…\zettcode.exe" gateway …` on Windows.
|
|
403
|
+
return re.search(r"(?:^|[\s/\\\"])zettcode(?:\.exe)?\"?\s+gateway(?:\s|$)", text, re.IGNORECASE) is not None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _connectable(settings: Settings) -> bool:
|
|
407
|
+
"""Return whether something accepts a connection at the gateway's address."""
|
|
408
|
+
host = settings.host
|
|
409
|
+
if host == "0.0.0.0":
|
|
410
|
+
host = "127.0.0.1"
|
|
411
|
+
elif host == "::":
|
|
412
|
+
host = "::1"
|
|
413
|
+
try:
|
|
414
|
+
with socket.create_connection((host, settings.port), timeout=0.25):
|
|
415
|
+
return True
|
|
416
|
+
except OSError:
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _wait_gone(pid: int, seconds: float) -> bool:
|
|
421
|
+
"""Wait until the process is no longer there: its command line cannot be read *and* it does not exist."""
|
|
422
|
+
deadline = time.monotonic() + seconds
|
|
423
|
+
while time.monotonic() < deadline:
|
|
424
|
+
if _gone(pid):
|
|
425
|
+
return True
|
|
426
|
+
time.sleep(POLL)
|
|
427
|
+
return _gone(pid)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _gone(pid: int) -> bool:
|
|
431
|
+
"""Return whether the process is gone, not merely unreadable."""
|
|
432
|
+
return command_line(pid) is None and not still_there(pid)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _force(pid: int, seconds: float) -> bool:
|
|
436
|
+
"""Make the process stop, after it ignored the signal to leave — but only while it is still this gateway."""
|
|
437
|
+
if _wait_gone(pid, 0.0):
|
|
438
|
+
return True
|
|
439
|
+
text = command_line(pid)
|
|
440
|
+
if text is None:
|
|
441
|
+
return False # it cannot be identified any more: report that rather than kill on a guess
|
|
442
|
+
if not _is_gateway(text):
|
|
443
|
+
return True # the pid now belongs to something else; leave it alone
|
|
444
|
+
try:
|
|
445
|
+
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
|
446
|
+
except OSError:
|
|
447
|
+
return True
|
|
448
|
+
return _wait_gone(pid, seconds)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _directory_from(args: argparse.Namespace) -> Path:
|
|
452
|
+
"""Return the gateway folder the arguments name, without building (and validating) every other setting."""
|
|
453
|
+
given = getattr(args, "directory", None)
|
|
454
|
+
return given.expanduser() if given is not None else default_directory()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _say(settings: Settings) -> None:
|
|
458
|
+
"""Print where the page is, how to sign in, and what its agents may do."""
|
|
459
|
+
print(version_text(), flush=True)
|
|
460
|
+
if settings.open:
|
|
461
|
+
print(f" open {settings.url}", flush=True)
|
|
462
|
+
print(" sign-in none: anybody on this machine can use it (give --token to ask for one)", flush=True)
|
|
463
|
+
else:
|
|
464
|
+
print(f" open {settings.url}/#token={settings.token}", flush=True)
|
|
465
|
+
anywhere = len(settings.roots) == 1 and settings.roots[0] == settings.roots[0].parent
|
|
466
|
+
print(
|
|
467
|
+
" agents may be started in: " + ("any directory" if anywhere else ", ".join(map(str, settings.roots))),
|
|
468
|
+
flush=True,
|
|
469
|
+
)
|
|
470
|
+
print(f" shell full level {'allowed' if settings.allow_full else 'not allowed (--no-full)'}", flush=True)
|
|
471
|
+
if settings.host not in LOOPBACK:
|
|
472
|
+
print(
|
|
473
|
+
f" warning listening on {settings.host}: anyone who can reach this port and has the token can run "
|
|
474
|
+
"commands as you. This page is plain HTTP; put it behind TLS.",
|
|
475
|
+
flush=True,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
__all__ = [
|
|
480
|
+
"GATEWAY_MODULE",
|
|
481
|
+
"build_parser",
|
|
482
|
+
"main",
|
|
483
|
+
"restart",
|
|
484
|
+
"serve",
|
|
485
|
+
"settings_from",
|
|
486
|
+
"spawn",
|
|
487
|
+
"start",
|
|
488
|
+
"stop",
|
|
489
|
+
"version_text",
|
|
490
|
+
"wait_serving",
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
if __name__ == "__main__":
|
|
495
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The file that says a gateway is serving, so ``zettcode gateway stop`` and ``restart`` can find it.
|
|
2
|
+
|
|
3
|
+
A process id on its own is not a name: it outlives the process and the system reuses it. So the command that ends a
|
|
4
|
+
gateway checks the process it reads here against its command line before sending anything (see
|
|
5
|
+
:mod:`zettcode.processes`); this module only keeps the record — the pid and the settings the gateway is serving with,
|
|
6
|
+
so a restart keeps its host, port and token instead of quietly changing them.
|
|
7
|
+
|
|
8
|
+
The sign-in token is in the file because of that, which is why it is written in one replace into a folder only its
|
|
9
|
+
owner may list, as the broker's note is. A gateway that was killed cannot tidy up, so a record is only ever a hint:
|
|
10
|
+
the reader validates it, and a stale one is removed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TypeGuard
|
|
21
|
+
|
|
22
|
+
from .settings import Settings
|
|
23
|
+
|
|
24
|
+
#: The record of the serving gateway, and the log a gateway started in the background writes.
|
|
25
|
+
RUN_FILE = "run.json"
|
|
26
|
+
LOG_FILE = "gateway.log"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Running:
|
|
31
|
+
"""A gateway that left a record: the process serving, and what it was started with."""
|
|
32
|
+
|
|
33
|
+
pid: int
|
|
34
|
+
settings: Settings
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def path(directory: Path) -> Path:
|
|
38
|
+
"""Return the record's path inside the gateway's folder."""
|
|
39
|
+
return directory / RUN_FILE
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def log_path(directory: Path) -> Path:
|
|
43
|
+
"""Return the path a gateway started in the background writes its output to."""
|
|
44
|
+
return directory / LOG_FILE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ensure_directory(directory: Path) -> None:
|
|
48
|
+
"""Make the gateway's folder, readable by its owner only."""
|
|
49
|
+
created = not directory.exists()
|
|
50
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
if created and os.name != "nt":
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(directory, 0o700)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write(settings: Settings) -> None:
|
|
59
|
+
"""Record this process as the gateway serving ``settings``, in one replace so a reader never sees half a file."""
|
|
60
|
+
ensure_directory(settings.directory)
|
|
61
|
+
payload = {
|
|
62
|
+
"pid": os.getpid(),
|
|
63
|
+
"host": settings.host,
|
|
64
|
+
"port": settings.port,
|
|
65
|
+
"token": settings.token,
|
|
66
|
+
"roots": [str(root) for root in settings.roots],
|
|
67
|
+
"allow_full": settings.allow_full,
|
|
68
|
+
"log_level": settings.log_level,
|
|
69
|
+
}
|
|
70
|
+
descriptor, temporary = tempfile.mkstemp(dir=settings.directory, prefix=".run-", suffix=".tmp")
|
|
71
|
+
try:
|
|
72
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
73
|
+
json.dump(payload, handle)
|
|
74
|
+
os.replace(temporary, path(settings.directory))
|
|
75
|
+
except BaseException:
|
|
76
|
+
try:
|
|
77
|
+
os.unlink(temporary)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read(directory: Path) -> Running | None:
|
|
84
|
+
"""Return the recorded gateway, or ``None`` when there is no usable record.
|
|
85
|
+
|
|
86
|
+
A missing file, one that is not JSON, one that is not an object, and one with a field of the wrong kind are all
|
|
87
|
+
the same answer: nobody to stop. The pid is not checked here; whether that process is a gateway is the caller's
|
|
88
|
+
question, and it is the one that must not be guessed at.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
raw = json.loads(path(directory).read_text(encoding="utf-8"))
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
return None
|
|
94
|
+
if not isinstance(raw, dict):
|
|
95
|
+
return None
|
|
96
|
+
pid, port = raw.get("pid"), raw.get("port")
|
|
97
|
+
host, token, log_level = raw.get("host"), raw.get("token"), raw.get("log_level")
|
|
98
|
+
roots, allow_full = raw.get("roots"), raw.get("allow_full")
|
|
99
|
+
if not (_whole(pid) and pid > 0 and _whole(port) and 0 < port < 65536):
|
|
100
|
+
return None
|
|
101
|
+
if not (isinstance(host, str) and isinstance(token, str) and isinstance(log_level, str)):
|
|
102
|
+
return None
|
|
103
|
+
if not isinstance(allow_full, bool):
|
|
104
|
+
return None
|
|
105
|
+
if not isinstance(roots, list) or not all(isinstance(root, str) for root in roots):
|
|
106
|
+
return None
|
|
107
|
+
settings = Settings(
|
|
108
|
+
token=token,
|
|
109
|
+
host=host,
|
|
110
|
+
port=port,
|
|
111
|
+
roots=tuple(Path(root) for root in roots),
|
|
112
|
+
allow_full=allow_full,
|
|
113
|
+
# The folder the record was found in, not a path written inside it: a record cannot send a restart to another
|
|
114
|
+
# folder.
|
|
115
|
+
directory=directory,
|
|
116
|
+
log_level=log_level,
|
|
117
|
+
)
|
|
118
|
+
return Running(pid, settings)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def clear(directory: Path, *, pid: int | None = None) -> bool:
|
|
122
|
+
"""Remove the record, unless it belongs to another process than ``pid``; say whether it was removed."""
|
|
123
|
+
current = read(directory)
|
|
124
|
+
if current is None:
|
|
125
|
+
if pid is not None:
|
|
126
|
+
return False
|
|
127
|
+
try:
|
|
128
|
+
path(directory).unlink()
|
|
129
|
+
return True
|
|
130
|
+
except OSError:
|
|
131
|
+
return False
|
|
132
|
+
if pid is not None and current.pid != pid:
|
|
133
|
+
return False
|
|
134
|
+
try:
|
|
135
|
+
path(directory).unlink()
|
|
136
|
+
except OSError:
|
|
137
|
+
return False
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _whole(value: object) -> TypeGuard[int]:
|
|
142
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ["LOG_FILE", "RUN_FILE", "Running", "clear", "ensure_directory", "log_path", "path", "read", "write"]
|