procwatch-cli 0.1.1__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.
- procwatch/__init__.py +0 -0
- procwatch/cli.py +376 -0
- procwatch_cli-0.1.1.dist-info/METADATA +39 -0
- procwatch_cli-0.1.1.dist-info/RECORD +8 -0
- procwatch_cli-0.1.1.dist-info/WHEEL +5 -0
- procwatch_cli-0.1.1.dist-info/entry_points.txt +2 -0
- procwatch_cli-0.1.1.dist-info/licenses/LICENSE +0 -0
- procwatch_cli-0.1.1.dist-info/top_level.txt +1 -0
procwatch/__init__.py
ADDED
|
File without changes
|
procwatch/cli.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, List, Optional
|
|
12
|
+
|
|
13
|
+
import psutil
|
|
14
|
+
|
|
15
|
+
# ----------------------------
|
|
16
|
+
# Presets (Variables)
|
|
17
|
+
# ----------------------------
|
|
18
|
+
|
|
19
|
+
PID = "pid"
|
|
20
|
+
NAME = "name"
|
|
21
|
+
USERNAME = "username"
|
|
22
|
+
STATUS = "status"
|
|
23
|
+
CMDLINE = "cmdline"
|
|
24
|
+
PROCESS_FIELDS = [PID, NAME, USERNAME, STATUS, CMDLINE]
|
|
25
|
+
assert all(f in psutil.Process().as_dict().keys() for f in PROCESS_FIELDS)
|
|
26
|
+
CONNECTIONS = "connections"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
NO_MATCH_FOUND = "No matching process found."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ----------------------------
|
|
33
|
+
# Logging
|
|
34
|
+
# ----------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class JsonFormatter(logging.Formatter):
|
|
38
|
+
def format(self, record):
|
|
39
|
+
log_record = {
|
|
40
|
+
"time": self.formatTime(record),
|
|
41
|
+
"level": record.levelname,
|
|
42
|
+
"message": record.getMessage(),
|
|
43
|
+
}
|
|
44
|
+
return json.dumps(log_record)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def log_config(logfile: str | None = None, json_out: bool = False):
|
|
48
|
+
formatter = (
|
|
49
|
+
JsonFormatter()
|
|
50
|
+
if json_out
|
|
51
|
+
else logging.Formatter("%(asctime)s | %(levelname)-8s | %(message)s")
|
|
52
|
+
)
|
|
53
|
+
logger = logging.getLogger()
|
|
54
|
+
logger.setLevel(logging.INFO)
|
|
55
|
+
|
|
56
|
+
# stdout handler
|
|
57
|
+
stream_handler = logging.StreamHandler(sys.stdout)
|
|
58
|
+
stream_handler.setFormatter(formatter)
|
|
59
|
+
logger.addHandler(stream_handler)
|
|
60
|
+
|
|
61
|
+
# file handler
|
|
62
|
+
|
|
63
|
+
if logfile:
|
|
64
|
+
try:
|
|
65
|
+
file_handler = logging.FileHandler(logfile)
|
|
66
|
+
file_handler.setFormatter(formatter)
|
|
67
|
+
logger.addHandler(file_handler)
|
|
68
|
+
except (PermissionError, FileNotFoundError) as e:
|
|
69
|
+
logger.error(f"Log File Error: {e}")
|
|
70
|
+
|
|
71
|
+
return logger
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ----------------------------
|
|
75
|
+
# CLI
|
|
76
|
+
# ----------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def valid_port(value: str) -> int:
|
|
80
|
+
port = int(value)
|
|
81
|
+
if not 1 <= port <= 65535:
|
|
82
|
+
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
|
|
83
|
+
return port
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def valid_pid(value: str) -> int:
|
|
87
|
+
pid = int(value)
|
|
88
|
+
if pid <= 0:
|
|
89
|
+
raise argparse.ArgumentTypeError("PID must be > 0")
|
|
90
|
+
return pid
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_args(args: list[str] | None = None) -> argparse.Namespace:
|
|
94
|
+
parser = argparse.ArgumentParser(description="Process watchdog")
|
|
95
|
+
|
|
96
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
97
|
+
group.add_argument("--pid", type=valid_pid, help="Monitor specific PID, (PID > 0)")
|
|
98
|
+
group.add_argument(
|
|
99
|
+
"--port", type=valid_port, help="Monitor service by port, (1 <= port <= 65535)"
|
|
100
|
+
)
|
|
101
|
+
group.add_argument(
|
|
102
|
+
"--name", type=str.lower, help="Monitor process by name, (substring match)"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
parser.add_argument("--first", action="store_true", help="Match first process only")
|
|
106
|
+
parser.add_argument("--print", action="store_true", help="Print matching processes")
|
|
107
|
+
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--interval",
|
|
110
|
+
type=int,
|
|
111
|
+
help="Monitor continuously every N seconds",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--restart-cmd",
|
|
116
|
+
type=str,
|
|
117
|
+
help="Command to restart the service",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
parser.add_argument("--log-file", type=str, help="Output log to log-file")
|
|
121
|
+
parser.add_argument("--json", action="store_true", help="Output in json")
|
|
122
|
+
parser.add_argument("--pretty", action="store_true", help="Output in pretty json")
|
|
123
|
+
|
|
124
|
+
args_parsed = parser.parse_args(args)
|
|
125
|
+
|
|
126
|
+
if args_parsed.pretty and not args_parsed.json:
|
|
127
|
+
parser.error("--pretty requires --json")
|
|
128
|
+
|
|
129
|
+
if args_parsed.first and not (args_parsed.name or args_parsed.port):
|
|
130
|
+
parser.error("--first requires --name or --port")
|
|
131
|
+
|
|
132
|
+
return args_parsed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ----------------------------
|
|
136
|
+
# Process Filters
|
|
137
|
+
# ----------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_self_and_parent_pids() -> set[int]:
|
|
141
|
+
pids = set()
|
|
142
|
+
p: psutil.Process | None = psutil.Process(os.getpid())
|
|
143
|
+
|
|
144
|
+
while p:
|
|
145
|
+
pids.add(p.pid)
|
|
146
|
+
p = p.parent()
|
|
147
|
+
|
|
148
|
+
return pids
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def filter_by_pid(pid: int) -> Optional[psutil.Process]:
|
|
152
|
+
try:
|
|
153
|
+
return psutil.Process(pid)
|
|
154
|
+
except psutil.Error:
|
|
155
|
+
return None
|
|
156
|
+
except (ValueError, TypeError) as e:
|
|
157
|
+
print(f"Error pid filter: {e}")
|
|
158
|
+
raise SystemExit(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def filter_by_name(name: str, first: bool = False) -> List[psutil.Process]:
|
|
162
|
+
results = []
|
|
163
|
+
ignore_pids = get_self_and_parent_pids()
|
|
164
|
+
|
|
165
|
+
for proc in psutil.process_iter([NAME, CMDLINE]):
|
|
166
|
+
try:
|
|
167
|
+
if proc.pid in ignore_pids:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
proc_name = (proc.info[NAME] or "").lower()
|
|
171
|
+
cmd = " ".join(proc.info[CMDLINE] or []).lower()
|
|
172
|
+
|
|
173
|
+
# exact match of name means no more search
|
|
174
|
+
if name == cmd:
|
|
175
|
+
return [proc]
|
|
176
|
+
|
|
177
|
+
# part of name match means more search
|
|
178
|
+
if name in proc_name or name in cmd:
|
|
179
|
+
results.append(proc)
|
|
180
|
+
|
|
181
|
+
if first:
|
|
182
|
+
break
|
|
183
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
return results
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def filter_by_port(port: int, first: bool = False) -> List[psutil.Process]:
|
|
190
|
+
results: set[psutil.Process] = set()
|
|
191
|
+
ignore_pids = get_self_and_parent_pids()
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
for conn in psutil.net_connections(kind="inet"):
|
|
195
|
+
# skip if no address or wrong port
|
|
196
|
+
if not conn.laddr or conn.laddr.port != port:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
pid = conn.pid
|
|
200
|
+
|
|
201
|
+
# warn if port matches but could not retrieve pid
|
|
202
|
+
if conn.laddr.port == port and pid is None:
|
|
203
|
+
logging.error(
|
|
204
|
+
f"PIDERROR: Port Found - '{conn.laddr}' but PID is {pid}. Sign of PermissionError."
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# skip kernel sockets or our own process tree
|
|
209
|
+
if pid is None or pid in ignore_pids:
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
proc = psutil.Process(pid)
|
|
214
|
+
results.add(proc)
|
|
215
|
+
|
|
216
|
+
if first:
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
except psutil.Error:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
return list(results)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ----------------------------
|
|
229
|
+
# Restart Logic
|
|
230
|
+
# ----------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def restart_service(command: str, timeout: int = 10) -> bool:
|
|
234
|
+
try:
|
|
235
|
+
logging.warning(f"Restarting service using: {command}")
|
|
236
|
+
|
|
237
|
+
result = subprocess.run(
|
|
238
|
+
shlex.split(command),
|
|
239
|
+
capture_output=True,
|
|
240
|
+
text=True,
|
|
241
|
+
timeout=timeout,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if result.returncode == 0:
|
|
245
|
+
logging.info("Restart successful")
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
logging.error(result.stderr.strip())
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
except subprocess.TimeoutExpired:
|
|
252
|
+
logging.error("Restart command timed out")
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logging.error(f"Restart failed: {e}")
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ----------------------------
|
|
261
|
+
# Output
|
|
262
|
+
# ----------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def output_processes(
|
|
266
|
+
processes: List[psutil.Process],
|
|
267
|
+
json_out: bool = False,
|
|
268
|
+
pretty: bool = True,
|
|
269
|
+
conn_port: int | None = None,
|
|
270
|
+
):
|
|
271
|
+
if not processes:
|
|
272
|
+
logging.error(f"OUTPUT: {NO_MATCH_FOUND}")
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
data: list[dict[str, Any]] = []
|
|
276
|
+
|
|
277
|
+
for p in processes:
|
|
278
|
+
try:
|
|
279
|
+
# 1. Get the standard fields first
|
|
280
|
+
proc_dict = p.as_dict(PROCESS_FIELDS)
|
|
281
|
+
|
|
282
|
+
# 2. Try to get network info (the high-privilege part)
|
|
283
|
+
try:
|
|
284
|
+
conns = [
|
|
285
|
+
conn.laddr
|
|
286
|
+
for conn in p.net_connections(kind="inet")
|
|
287
|
+
if conn.laddr
|
|
288
|
+
and (conn_port is None or conn.laddr.port == conn_port)
|
|
289
|
+
]
|
|
290
|
+
proc_dict[CONNECTIONS] = conns
|
|
291
|
+
except psutil.AccessDenied as e:
|
|
292
|
+
logging.error(
|
|
293
|
+
f"OUTPUT: Access Denied for PID {p.pid}: {e}. "
|
|
294
|
+
"Running with root privileges might help."
|
|
295
|
+
)
|
|
296
|
+
proc_dict[CONNECTIONS] = None
|
|
297
|
+
|
|
298
|
+
data.append(proc_dict)
|
|
299
|
+
|
|
300
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
301
|
+
# If the process disappears or we can't even get basic info, skip it
|
|
302
|
+
continue
|
|
303
|
+
except psutil.Error:
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# Output section
|
|
307
|
+
if json_out:
|
|
308
|
+
indent = 4 if pretty else None
|
|
309
|
+
print(json.dumps(data, indent=indent))
|
|
310
|
+
else:
|
|
311
|
+
# Renamed variable to 'item' to avoid Mypy type conflict with 'p'
|
|
312
|
+
for item in data:
|
|
313
|
+
print(item)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ----------------------------
|
|
317
|
+
# Monitor Loop
|
|
318
|
+
# ----------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def check_process(args: argparse.Namespace) -> List[psutil.Process]:
|
|
322
|
+
|
|
323
|
+
if args.pid:
|
|
324
|
+
proc = filter_by_pid(args.pid)
|
|
325
|
+
return [proc] if proc else []
|
|
326
|
+
|
|
327
|
+
if args.name:
|
|
328
|
+
return filter_by_name(args.name, args.first)
|
|
329
|
+
|
|
330
|
+
if args.port:
|
|
331
|
+
return filter_by_port(args.port, args.first)
|
|
332
|
+
|
|
333
|
+
return []
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def run_watchdog(args: argparse.Namespace):
|
|
337
|
+
|
|
338
|
+
while True:
|
|
339
|
+
processes = check_process(args)
|
|
340
|
+
|
|
341
|
+
if processes:
|
|
342
|
+
logging.info("Service healthy. No restart required.")
|
|
343
|
+
else:
|
|
344
|
+
logging.warning("Service not running")
|
|
345
|
+
|
|
346
|
+
if args.restart_cmd:
|
|
347
|
+
restart_service(args.restart_cmd)
|
|
348
|
+
if args.print:
|
|
349
|
+
output_processes(processes, args.json, args.pretty, args.port)
|
|
350
|
+
|
|
351
|
+
if not args.interval:
|
|
352
|
+
break
|
|
353
|
+
|
|
354
|
+
time.sleep(args.interval)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ----------------------------
|
|
358
|
+
# Main
|
|
359
|
+
# ----------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def main():
|
|
363
|
+
args = parse_args()
|
|
364
|
+
log_file = args.log_file
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
log_config(log_file, args.json)
|
|
368
|
+
run_watchdog(args)
|
|
369
|
+
except KeyboardInterrupt:
|
|
370
|
+
logging.info("Watchdog stopped")
|
|
371
|
+
except Exception:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: procwatch-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Process watchdog CLI for monitoring and restarting services
|
|
5
|
+
Author: Your Name
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: psutil>=5.9
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: types-psutil; extra == "dev"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# 🔹 Task 3: Service Monitor & Auto-Restart
|
|
15
|
+
|
|
16
|
+
## Goal:
|
|
17
|
+
Monitor if a process is running. If not, restart it.
|
|
18
|
+
|
|
19
|
+
## Example:
|
|
20
|
+
`procwatch --pid 34349`
|
|
21
|
+
<!-- `procwatch --pidfile app.pid` -->
|
|
22
|
+
`procwatch --match "python server.py"`
|
|
23
|
+
`procwatch --port 8000`
|
|
24
|
+
|
|
25
|
+
## Requirements:
|
|
26
|
+
- Check every N seconds
|
|
27
|
+
- Log events
|
|
28
|
+
- Handle failures
|
|
29
|
+
- Timeout protection
|
|
30
|
+
|
|
31
|
+
## Multiple cli
|
|
32
|
+
`procwatch --match "python -m http.server 8000" --restart "python -m http.server 8000"`
|
|
33
|
+
`procwatch --port 8000 --restart "/usr/bin/startmyapp" --interval 4`
|
|
34
|
+
|
|
35
|
+
## Skills Learned:
|
|
36
|
+
- Loop automation
|
|
37
|
+
- Subprocess execution
|
|
38
|
+
- Timeouts
|
|
39
|
+
- Basic watchdog design
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
procwatch/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
procwatch/cli.py,sha256=u_AiNASuUeIe8PLpoX33GI8kTTaNUpk3W9KYvQ9V4H8,9725
|
|
3
|
+
procwatch_cli-0.1.1.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
procwatch_cli-0.1.1.dist-info/METADATA,sha256=chI5TXZHA2xF5AVqgJfmRrZ-iCgXZgKP9ecL0k4yK8g,963
|
|
5
|
+
procwatch_cli-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
procwatch_cli-0.1.1.dist-info/entry_points.txt,sha256=2zgMkPdC4Y4XjZyro6PR29u7iNbo7fldP8V7lGKHrHg,49
|
|
7
|
+
procwatch_cli-0.1.1.dist-info/top_level.txt,sha256=PcGcas9WIR248safqQbbB5UWSu60PoyZtiYVDQ-MhZc,10
|
|
8
|
+
procwatch_cli-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
procwatch
|