ppkit 0.1.0__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.
- pp/__init__.py +4 -0
- pp/__main__.py +5 -0
- pp/cli.py +363 -0
- pp/commands/__init__.py +1 -0
- pp/commands/batch.py +848 -0
- pp/commands/cmd.py +545 -0
- pp/commands/completion.py +55 -0
- pp/commands/http.py +1499 -0
- pp/commands/kill.py +293 -0
- pp/commands/promote.py +945 -0
- pp/commands/queue.py +243 -0
- pp/lib/__init__.py +1 -0
- pp/lib/config.py +110 -0
- pp/lib/jobq.py +3106 -0
- pp/lib/lock.py +338 -0
- pp/lib/paths.py +163 -0
- pp/lib/proc.py +706 -0
- pp/lib/session.py +31 -0
- pp/lib/ui.py +189 -0
- ppkit-0.1.0.dist-info/METADATA +330 -0
- ppkit-0.1.0.dist-info/RECORD +24 -0
- ppkit-0.1.0.dist-info/WHEEL +4 -0
- ppkit-0.1.0.dist-info/entry_points.txt +3 -0
- ppkit-0.1.0.dist-info/licenses/LICENSE +202 -0
pp/commands/kill.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Kill processes by pid, port or pattern, never the shell that asked
|
|
2
|
+
|
|
3
|
+
pp kill 12345 that pid (SIGTERM, then SIGKILL after --grace seconds if it is still there)
|
|
4
|
+
pp kill -s HUP 12345 another signal; only TERM, INT and QUIT are followed up with SIGKILL
|
|
5
|
+
pp kill :8080 whatever listens on port 8080 (tcp/udp, IPv4/IPv6)
|
|
6
|
+
pp kill 'vite --port' every process whose command line matches the regex; shows them and asks first
|
|
7
|
+
pp kill -x node processes NAMED exactly node (not ones merely mentioning it; names past the
|
|
8
|
+
kernel's 15-character cut match by their program's file name)
|
|
9
|
+
pp kill -n :8080 only show what would be killed
|
|
10
|
+
pp kill --tree 12345 the pid and all its descendants
|
|
11
|
+
pp kill --ports list every listening port with its owner (or docker container)
|
|
12
|
+
|
|
13
|
+
Patterns never match pp itself or any of its ancestors. That is the `pkill -f PATTERN` trap: the pattern is in the
|
|
14
|
+
command line of the shell running pkill, so pkill kills its own shell (exit 143 from `sh -c "pkill -f foo"`,
|
|
15
|
+
which is the SIGTERM it sent itself).
|
|
16
|
+
Patterns only match your own processes unless --any-user.
|
|
17
|
+
|
|
18
|
+
Asking: pid and port targets are explicit and act at once; a pattern shows its matches and asks. Without a
|
|
19
|
+
terminal it refuses unless -y (agents: `-n` to look, then `-y`). A port published by docker is held by root's
|
|
20
|
+
docker-proxy: pp names the container and offers `docker stop` instead of killing the proxy.
|
|
21
|
+
|
|
22
|
+
--tree: pp takes each target's descendants again when it signals, and once more after the grace window, so a
|
|
23
|
+
child forked while it was looking or asking is signalled too. A child started after that last look is out of
|
|
24
|
+
scope. One still alive at the end is listed with the others and the exit status is 1.
|
|
25
|
+
|
|
26
|
+
Exit: 0 every target is gone, or signalled where the signal is not followed up with SIGKILL (`-s` naming
|
|
27
|
+
anything but TERM, INT, QUIT or KILL, or `--no-escalate`): a target meant to survive the signal is a success.
|
|
28
|
+
1 something survived a signal meant to end it, was denied, or nothing matched. 2 refused or bad usage.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import pwd
|
|
34
|
+
import re
|
|
35
|
+
import shutil
|
|
36
|
+
import signal
|
|
37
|
+
|
|
38
|
+
from pp.lib import proc, ui
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _user(uid: int) -> str:
|
|
42
|
+
try:
|
|
43
|
+
return pwd.getpwuid(uid).pw_name
|
|
44
|
+
except KeyError:
|
|
45
|
+
return str(uid)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sig(s: str) -> int:
|
|
49
|
+
s = s.upper()
|
|
50
|
+
if s.isdigit():
|
|
51
|
+
return int(s)
|
|
52
|
+
try:
|
|
53
|
+
return int(getattr(signal, s if s.startswith("SIG") else "SIG" + s))
|
|
54
|
+
except AttributeError:
|
|
55
|
+
ui.die(f"unknown signal {s}", 2)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def docker_ports() -> dict[int, list[str]]:
|
|
59
|
+
"""host port -> container names publishing it ({} without docker or when it does not answer quickly)."""
|
|
60
|
+
if not shutil.which("docker"):
|
|
61
|
+
return {}
|
|
62
|
+
try:
|
|
63
|
+
r = proc.run(["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"], group="own", stdin="none",
|
|
64
|
+
env="inherit", timeout=5)
|
|
65
|
+
except OSError:
|
|
66
|
+
return {}
|
|
67
|
+
if r.returncode != 0: # it did not answer in time, or it complained: no container to name
|
|
68
|
+
return {}
|
|
69
|
+
res: dict[int, list[str]] = {}
|
|
70
|
+
for line in r.stdout.splitlines():
|
|
71
|
+
name, _, ports = line.partition("\t")
|
|
72
|
+
# "0.0.0.0:8080->80/tcp, [::]:8080->80/tcp, 0.0.0.0:9000-9002->9000-9002/tcp"
|
|
73
|
+
for m in re.finditer(r":(\d+)(?:-(\d+))?->", ports):
|
|
74
|
+
lo, hi = int(m.group(1)), int(m.group(2) or m.group(1))
|
|
75
|
+
for p in range(lo, hi + 1):
|
|
76
|
+
if name not in res.setdefault(p, []):
|
|
77
|
+
res[p].append(name)
|
|
78
|
+
return res
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# a signal sent to end a process: one that survives it gets SIGKILL after --grace. Any other (-s HUP to reload,
|
|
82
|
+
# USR1, STOP, 0) is sent alone, because the process is meant to survive it.
|
|
83
|
+
ENDING = {signal.SIGTERM, signal.SIGINT, signal.SIGQUIT, signal.SIGKILL}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _names(p: proc.Proc) -> set[str]:
|
|
87
|
+
"""What -x compares: comm, and argv[0]'s file name; comm is cut at 15 characters (systemd-journald is
|
|
88
|
+
"systemd-journal"), so a longer name only matches the program's own."""
|
|
89
|
+
return {p.comm, os.path.basename(p.cmdline[0])} if p.cmdline else {p.comm}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _shown(cmd: str, width: int = 120) -> str:
|
|
93
|
+
"""A command line as the table shows it: control characters escaped (another program chooses its argv, and an
|
|
94
|
+
escape sequence or newline in it must not redraw what you are asked to confirm), cut with … past width."""
|
|
95
|
+
cmd = ui.printable(cmd, tabs=False)
|
|
96
|
+
return cmd if len(cmd) <= width else cmd[:width - 1] + "…"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _row(p: proc.Proc, why: str) -> list:
|
|
100
|
+
return [p.pid, _user(p.uid), ui.human_duration(p.age()), ui.human_size(p.rss_kb * 1024), why, _shown(p.cmd)]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _tree_now(trees: list[int], known: dict[int, int], me: set[int]) -> dict[int, proc.Proc]:
|
|
104
|
+
"""The descendants of each --tree target that are not in `known` yet, read now. The plan comes from one
|
|
105
|
+
snapshot, so a tree is taken again before pp signals and again after the grace window: a child forked in
|
|
106
|
+
between is in scope, and nothing is reported gone that pp never looked for."""
|
|
107
|
+
if not trees:
|
|
108
|
+
return {}
|
|
109
|
+
procs = proc.processes()
|
|
110
|
+
return {d: procs[d] for pid in trees for d in proc.descendants(pid, procs)
|
|
111
|
+
if d not in known and d not in me}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_ports(a) -> int:
|
|
115
|
+
socks = proc.sockets()
|
|
116
|
+
owners = proc.socket_owners({s["inode"] for s in socks})
|
|
117
|
+
dports = docker_ports()
|
|
118
|
+
procs = {}
|
|
119
|
+
rows, js = [], []
|
|
120
|
+
for s in sorted(socks, key=lambda s: (s["port"], s["proto"])):
|
|
121
|
+
pids = sorted(owners.get(s["inode"], set()))
|
|
122
|
+
who = []
|
|
123
|
+
for pid in pids:
|
|
124
|
+
p = procs.setdefault(pid, proc.read(pid))
|
|
125
|
+
if p:
|
|
126
|
+
who.append(f"{pid} {_shown(p.cmd, 70)}")
|
|
127
|
+
if not who and s["port"] in dports:
|
|
128
|
+
who = ["docker: " + ", ".join(dports[s["port"]])]
|
|
129
|
+
if not who:
|
|
130
|
+
who = ["? (another user's)"]
|
|
131
|
+
addr = f"[{s['addr']}]" if ":" in s["addr"] else s["addr"]
|
|
132
|
+
rows.append([s["proto"], f"{addr}:{s['port']}", "; ".join(who)])
|
|
133
|
+
js.append({**s, "pids": pids, "docker": dports.get(s["port"], [])})
|
|
134
|
+
if a.json:
|
|
135
|
+
ui.print_json(js)
|
|
136
|
+
else:
|
|
137
|
+
print(ui.table(rows, ["proto", "address", "owner"]))
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main(argv: list[str]) -> int:
|
|
142
|
+
ap = ui.parser("kill", __doc__)
|
|
143
|
+
ap.add_argument("targets", nargs="*", metavar="TARGET", help="PID, :PORT, or a regex over the command line")
|
|
144
|
+
ap.add_argument("-n", "--dry-run", action="store_true", help="show what would be signalled, do nothing")
|
|
145
|
+
ap.add_argument("-y", "--yes", action="store_true", help="do not ask (patterns, docker stop)")
|
|
146
|
+
ap.add_argument("-s", "--signal", default="TERM", help="signal to send first (default TERM)")
|
|
147
|
+
ap.add_argument("-9", dest="nine", action="store_true", help="SIGKILL straight away")
|
|
148
|
+
ap.add_argument("--grace", type=float, default=3.0,
|
|
149
|
+
help="seconds before escalating to SIGKILL (default 3; after TERM, INT or QUIT only)")
|
|
150
|
+
ap.add_argument("--no-escalate", action="store_true", help="send the signal only; never SIGKILL")
|
|
151
|
+
ap.add_argument("--tree", action="store_true", help="also every descendant of each target")
|
|
152
|
+
ap.add_argument("-x", "--exact", action="store_true", help="pattern is the exact process name (comm, or its program's file name)")
|
|
153
|
+
ap.add_argument("-F", "--fixed", action="store_true", help="pattern is a plain string, not a regex")
|
|
154
|
+
ap.add_argument("-i", "--ignore-case", action="store_true")
|
|
155
|
+
ap.add_argument("--any-user", action="store_true", help="patterns may match other users' processes")
|
|
156
|
+
ap.add_argument("--ports", action="store_true", help="list listening ports and their owners")
|
|
157
|
+
ap.add_argument("--json", action="store_true")
|
|
158
|
+
a = ap.parse_args(argv)
|
|
159
|
+
|
|
160
|
+
if a.ports:
|
|
161
|
+
return cmd_ports(a)
|
|
162
|
+
if not a.targets:
|
|
163
|
+
ap.print_usage()
|
|
164
|
+
return 2
|
|
165
|
+
sig = signal.SIGKILL if a.nine else _sig(a.signal) # a typo fails before anything is shown or asked
|
|
166
|
+
|
|
167
|
+
procs = proc.processes()
|
|
168
|
+
me = set(proc.ancestors(procs=procs))
|
|
169
|
+
uid = os.getuid()
|
|
170
|
+
chosen: dict[int, str] = {} # pid -> why it was chosen
|
|
171
|
+
ask = False
|
|
172
|
+
containers: list[tuple[int, list[str]]] = []
|
|
173
|
+
problems: list[str] = []
|
|
174
|
+
|
|
175
|
+
for t in a.targets:
|
|
176
|
+
if t.isdigit():
|
|
177
|
+
pid = int(t)
|
|
178
|
+
if pid not in procs:
|
|
179
|
+
problems.append(f"no process {pid}")
|
|
180
|
+
elif pid in me:
|
|
181
|
+
problems.append(f"{pid} is pp itself, its caller or an ancestor of both; not killing it")
|
|
182
|
+
else:
|
|
183
|
+
chosen.setdefault(pid, f"pid {pid}")
|
|
184
|
+
elif re.fullmatch(r":\d+", t):
|
|
185
|
+
port = int(t[1:])
|
|
186
|
+
if not 0 < port < 65536:
|
|
187
|
+
ui.die(f"{t}: a port is 1-65535", 2)
|
|
188
|
+
socks, pids = proc.port_owners(port)
|
|
189
|
+
if not socks:
|
|
190
|
+
problems.append(f"nothing listens on port {port}")
|
|
191
|
+
continue
|
|
192
|
+
for pid in sorted(pids):
|
|
193
|
+
if pid in me:
|
|
194
|
+
problems.append(f"port {port} is held by pp's own caller ({pid}); not killing it")
|
|
195
|
+
else:
|
|
196
|
+
chosen.setdefault(pid, f"port {port}")
|
|
197
|
+
if not pids:
|
|
198
|
+
names = docker_ports().get(port)
|
|
199
|
+
if names:
|
|
200
|
+
containers.append((port, names))
|
|
201
|
+
else:
|
|
202
|
+
problems.append(f"port {port} is held by another user's process; try: sudo pp kill :{port}")
|
|
203
|
+
else:
|
|
204
|
+
ask = True
|
|
205
|
+
flags = re.I if a.ignore_case else 0
|
|
206
|
+
try:
|
|
207
|
+
rx = re.compile(re.escape(t) if a.fixed else t, flags)
|
|
208
|
+
except re.error:
|
|
209
|
+
rx = re.compile(re.escape(t), flags)
|
|
210
|
+
hits = 0
|
|
211
|
+
for p in procs.values():
|
|
212
|
+
if p.pid in me or (p.uid != uid and not a.any_user) or not p.cmdline:
|
|
213
|
+
continue # never the caller; kernel threads have no cmdline
|
|
214
|
+
if (any(rx.fullmatch(n) for n in _names(p)) if a.exact else rx.search(p.cmd)):
|
|
215
|
+
chosen.setdefault(p.pid, f"/{t}/")
|
|
216
|
+
hits += 1
|
|
217
|
+
if not hits:
|
|
218
|
+
problems.append(f"no process matches {t!r}" + ("" if a.any_user else " (own processes only; --any-user widens)"))
|
|
219
|
+
|
|
220
|
+
for pid in list(chosen):
|
|
221
|
+
# a `:PORT` owner is read fresh and can be younger than the snapshot: put it in procs, or drop it, so
|
|
222
|
+
# every later use (the table, the uid warning, the JSON, the start times) reads a Proc that exists
|
|
223
|
+
p = procs.get(pid) or proc.read(pid)
|
|
224
|
+
if p:
|
|
225
|
+
procs[pid] = p
|
|
226
|
+
else:
|
|
227
|
+
del chosen[pid]
|
|
228
|
+
problems.append(f"no process {pid} any more")
|
|
229
|
+
|
|
230
|
+
trees = list(chosen) if a.tree else [] # the targets --tree expands, before their descendants join
|
|
231
|
+
for pid in trees:
|
|
232
|
+
for d in proc.descendants(pid, procs):
|
|
233
|
+
if d not in me:
|
|
234
|
+
chosen.setdefault(d, f"child of {pid}")
|
|
235
|
+
|
|
236
|
+
for msg in problems:
|
|
237
|
+
ui.warn(msg)
|
|
238
|
+
others = sorted({_user(procs[pid].uid) for pid in chosen if procs[pid].uid != uid})
|
|
239
|
+
if others and uid != 0:
|
|
240
|
+
ui.warn(f"{sum(procs[pid].uid != uid for pid in chosen)} of these belong to {', '.join(others)}:"
|
|
241
|
+
f" signalling them is denied unless pp runs as root")
|
|
242
|
+
if a.json and (a.dry_run or not chosen and not containers):
|
|
243
|
+
ui.print_json({"pids": {str(k): v for k, v in chosen.items()}, "containers": dict(containers),
|
|
244
|
+
"commands": {str(k): procs[k].cmd for k in chosen}, "problems": problems})
|
|
245
|
+
if not chosen and not containers:
|
|
246
|
+
return 1
|
|
247
|
+
|
|
248
|
+
if chosen and not a.json:
|
|
249
|
+
print(ui.table([_row(procs[pid], why) for pid, why in chosen.items()],
|
|
250
|
+
["pid", "user", "age", "rss", "matched", "command"]))
|
|
251
|
+
for port, names in containers:
|
|
252
|
+
print(f"port {port} is published by docker container {', '.join(names)}"
|
|
253
|
+
f"; `docker stop {' '.join(names)}` stops it")
|
|
254
|
+
|
|
255
|
+
if a.dry_run:
|
|
256
|
+
return 0
|
|
257
|
+
if (ask or containers) and not ui.confirm(f"signal {len(chosen)} process(es)" + (" and stop the container(s)" if containers else "") + "?", a.yes):
|
|
258
|
+
return 2
|
|
259
|
+
|
|
260
|
+
starts = {pid: procs[pid].start for pid in chosen}
|
|
261
|
+
for pid, p in _tree_now(trees, starts, me).items():
|
|
262
|
+
procs[pid], starts[pid] = p, p.start # a child forked while pp looked, or while it asked
|
|
263
|
+
escalate = sig in ENDING and not a.no_escalate
|
|
264
|
+
grace = a.grace if sig in ENDING else 0 # nothing to wait for after a signal it is meant to survive
|
|
265
|
+
res = proc.signal_and_wait(list(starts), sig, grace, escalate=escalate, starts=starts) if starts else {}
|
|
266
|
+
late = _tree_now(trees, starts, me) # and one forked while the targets were dying
|
|
267
|
+
if late:
|
|
268
|
+
for pid, p in late.items():
|
|
269
|
+
procs[pid], starts[pid] = p, p.start
|
|
270
|
+
res.update(proc.signal_and_wait(list(late), sig, grace, escalate=escalate, starts=starts))
|
|
271
|
+
if not escalate and sig != signal.SIGKILL: # e.g. -s HUP to make a daemon reload: surviving is the point
|
|
272
|
+
res = {k: ("signalled" if v == "alive" else v) for k, v in res.items()}
|
|
273
|
+
stopped = {}
|
|
274
|
+
for port, names in containers:
|
|
275
|
+
# docker stop gives each container 10s of its own to stop, so pp's own bound has to be the looser one
|
|
276
|
+
wait = 30.0 + 15 * len(names)
|
|
277
|
+
try:
|
|
278
|
+
r = proc.run(["docker", "stop", *names], group="own", stdin="none", env="inherit", timeout=wait)
|
|
279
|
+
except OSError as e:
|
|
280
|
+
stopped[",".join(names)] = f"failed: {e}"
|
|
281
|
+
continue
|
|
282
|
+
why = r.stderr.strip() or (f"it did not answer in {wait:g}s" if r.timed_out else "")
|
|
283
|
+
stopped[",".join(names)] = "stopped" if r.returncode == 0 else "failed: " + why
|
|
284
|
+
|
|
285
|
+
if a.json:
|
|
286
|
+
ui.print_json({"pids": {str(k): v for k, v in res.items()}, "containers": stopped,
|
|
287
|
+
"commands": {str(k): procs[k].cmd for k in res if k in procs}, "problems": problems})
|
|
288
|
+
else:
|
|
289
|
+
colour = {"exited": "green", "killed": "yellow", "gone": "dim", "stopped": "green", "signalled": "green"}
|
|
290
|
+
for k, v in [*res.items(), *((f"docker:{n}", v) for n, v in stopped.items())]:
|
|
291
|
+
print(f" {k}: {ui.c(v, colour.get(v, 'red'))}")
|
|
292
|
+
ok = {"exited", "killed", "gone", "stopped", "signalled"}
|
|
293
|
+
return 0 if all(v in ok for v in [*res.values(), *stopped.values()]) and not problems else 1
|