multivisor 6.0.2__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.
Files changed (34) hide show
  1. multivisor/__init__.py +0 -0
  2. multivisor/client/__init__.py +0 -0
  3. multivisor/client/cli.py +29 -0
  4. multivisor/client/http.py +93 -0
  5. multivisor/client/repl.py +244 -0
  6. multivisor/client/util.py +58 -0
  7. multivisor/multivisor.py +470 -0
  8. multivisor/rpc.py +232 -0
  9. multivisor/server/__init__.py +0 -0
  10. multivisor/server/dist/index.html +1 -0
  11. multivisor/server/dist/static/css/app.2aff3580a128440bba89b94112292cb1.css +6 -0
  12. multivisor/server/dist/static/css/app.2aff3580a128440bba89b94112292cb1.css.map +1 -0
  13. multivisor/server/dist/static/js/app.52791f915c2f060b9cb1.js +2 -0
  14. multivisor/server/dist/static/js/app.52791f915c2f060b9cb1.js.map +1 -0
  15. multivisor/server/dist/static/js/manifest.2ae2e69a05c33dfc65f8.js +2 -0
  16. multivisor/server/dist/static/js/manifest.2ae2e69a05c33dfc65f8.js.map +1 -0
  17. multivisor/server/dist/static/js/vendor.1d02877727062a41e9fb.js +1319 -0
  18. multivisor/server/dist/static/js/vendor.1d02877727062a41e9fb.js.map +1 -0
  19. multivisor/server/rpc.py +156 -0
  20. multivisor/server/tests/__init__.py +0 -0
  21. multivisor/server/tests/conftest.py +1 -0
  22. multivisor/server/tests/test_web.py +194 -0
  23. multivisor/server/util.py +70 -0
  24. multivisor/server/web.py +327 -0
  25. multivisor/signals.py +5 -0
  26. multivisor/tests/__init__.py +0 -0
  27. multivisor/tests/test_multivisor.py +179 -0
  28. multivisor/util.py +75 -0
  29. multivisor-6.0.2.dist-info/METADATA +375 -0
  30. multivisor-6.0.2.dist-info/RECORD +34 -0
  31. multivisor-6.0.2.dist-info/WHEEL +5 -0
  32. multivisor-6.0.2.dist-info/entry_points.txt +4 -0
  33. multivisor-6.0.2.dist-info/licenses/LICENSE +674 -0
  34. multivisor-6.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,327 @@
1
+ import hashlib
2
+ import functools
3
+
4
+ import gevent
5
+ from blinker import signal
6
+ from gevent.monkey import patch_all
7
+
8
+ patch_all(thread=False)
9
+
10
+ import os
11
+ import logging
12
+
13
+ from gevent import queue, sleep
14
+ from gevent.pywsgi import WSGIServer
15
+ from flask import Flask, render_template, Response, request, json, jsonify, session
16
+ from werkzeug.debug import DebuggedApplication
17
+ from werkzeug.serving import run_simple
18
+
19
+ from multivisor.signals import SIGNALS
20
+ from multivisor.util import sanitize_url
21
+ from multivisor.multivisor import Multivisor
22
+ from .util import is_login_valid, login_required
23
+
24
+
25
+ log = logging.getLogger("multivisor")
26
+
27
+ app = Flask(__name__, static_folder="./dist/static", template_folder="./dist")
28
+
29
+
30
+ @app.route("/api/admin/reload")
31
+ @login_required(app)
32
+ def reload():
33
+ app.multivisor.reload()
34
+ return "OK"
35
+
36
+
37
+ @app.route("/api/refresh")
38
+ @login_required(app)
39
+ def refresh():
40
+ app.multivisor.refresh()
41
+ return jsonify(app.multivisor.safe_config)
42
+
43
+
44
+ @app.route("/api/data")
45
+ @login_required(app)
46
+ def data():
47
+ return jsonify(app.multivisor.safe_config)
48
+
49
+
50
+ @app.route("/api/config/file")
51
+ @login_required(app)
52
+ def config_file_content():
53
+ content = app.multivisor.config_file_content
54
+ return jsonify(dict(content=content))
55
+
56
+
57
+ @app.route("/api/supervisor/update", methods=["POST"])
58
+ @login_required(app)
59
+ def update_supervisor():
60
+ names = (
61
+ str.strip(supervisor) for supervisor in request.form["supervisor"].split(",")
62
+ )
63
+ app.multivisor.update_supervisors(*names)
64
+ return "OK"
65
+
66
+
67
+ @app.route("/api/supervisor/restart", methods=["POST"])
68
+ @login_required(app)
69
+ def restart_supervisor():
70
+ names = (
71
+ str.strip(supervisor) for supervisor in request.form["supervisor"].split(",")
72
+ )
73
+ app.multivisor.restart_supervisors(*names)
74
+ return "OK"
75
+
76
+
77
+ @app.route("/api/supervisor/reread", methods=["POST"])
78
+ @login_required(app)
79
+ def reread_supervisor():
80
+ names = (
81
+ str.strip(supervisor) for supervisor in request.form["supervisor"].split(",")
82
+ )
83
+ app.multivisor.reread_supervisors(*names)
84
+ return "OK"
85
+
86
+
87
+ @app.route("/api/supervisor/shutdown", methods=["POST"])
88
+ @login_required(app)
89
+ def shutdown_supervisor():
90
+ names = (
91
+ str.strip(supervisor) for supervisor in request.form["supervisor"].split(",")
92
+ )
93
+ app.multivisor.shutdown_supervisors(*names)
94
+ return "OK"
95
+
96
+
97
+ @app.route("/api/process/restart", methods=["POST"])
98
+ @login_required(app)
99
+ def restart_process():
100
+ patterns = request.form["uid"].split(",")
101
+ procs = app.multivisor.restart_processes(*patterns)
102
+ return "OK"
103
+
104
+
105
+ @app.route("/api/process/stop", methods=["POST"])
106
+ @login_required(app)
107
+ def stop_process():
108
+ patterns = request.form["uid"].split(",")
109
+ app.multivisor.stop_processes(*patterns)
110
+ return "OK"
111
+
112
+
113
+ @app.route("/api/process/list")
114
+ @login_required(app)
115
+ def list_processes():
116
+ return jsonify(tuple(app.multivisor.processes.keys()))
117
+
118
+
119
+ @app.route("/api/process/info/<uid>")
120
+ @login_required(app)
121
+ def process_info(uid):
122
+ process = app.multivisor.get_process(uid)
123
+ process.refresh()
124
+ return json.dumps(process)
125
+
126
+
127
+ @app.route("/api/supervisor/info/<uid>")
128
+ @login_required(app)
129
+ def supervisor_info(uid):
130
+ supervisor = app.multivisor.get_supervisor(uid)
131
+ supervisor.refresh()
132
+ return json.dumps(supervisor)
133
+
134
+
135
+ @app.route("/api/process/log/<stream>/tail/<uid>")
136
+ @login_required(app)
137
+ def process_log_tail(stream, uid):
138
+ sname, pname = uid.split(":", 1)
139
+ supervisor = app.multivisor.get_supervisor(sname)
140
+ server = supervisor.server
141
+ if stream == "out":
142
+ tail = server.tailProcessStdoutLog
143
+ else:
144
+ tail = server.tailProcessStderrLog
145
+
146
+ def event_stream():
147
+ i, offset, length = 0, 0, 2 ** 12
148
+ while True:
149
+ data = tail(pname, offset, length)
150
+ log, offset, overflow = data
151
+ # don't care about overflow in first log message
152
+ if overflow and i:
153
+ length = min(length * 2, 2 ** 14)
154
+ else:
155
+ data = json.dumps(dict(message=log, size=offset))
156
+ yield "data: {}\n\n".format(data)
157
+ sleep(1)
158
+ i += 1
159
+
160
+ return Response(event_stream(), mimetype="text/event-stream")
161
+
162
+
163
+ @app.route("/api/login", methods=["post"])
164
+ def login():
165
+ if not app.multivisor.use_authentication:
166
+ return "Authentication is not required"
167
+ username = request.form.get("username")
168
+ password = request.form.get("password")
169
+ if is_login_valid(app, username, password):
170
+ session["username"] = username
171
+ return json.dumps({})
172
+ else:
173
+ response_data = {"errors": {"password": "Invalid username or password"}}
174
+ return json.dumps(response_data), 400
175
+
176
+
177
+ @app.route("/api/auth", methods=["get"])
178
+ def auth():
179
+ response_data = {
180
+ "use_authentication": app.multivisor.use_authentication,
181
+ "is_authenticated": "username" in session,
182
+ }
183
+ return json.dumps(response_data)
184
+
185
+
186
+ @app.route("/api/logout", methods=["post"])
187
+ def logout():
188
+ session.clear()
189
+ return json.dumps({})
190
+
191
+
192
+ @app.route("/api/stream")
193
+ @login_required(app)
194
+ def stream():
195
+ def event_stream():
196
+ client = queue.Queue()
197
+ app.dispatcher.add_listener(client)
198
+ for event in client:
199
+ yield event
200
+ app.dispatcher.remove_listener(client)
201
+
202
+ return Response(event_stream(), mimetype="text/event-stream")
203
+
204
+
205
+ @app.route("/", defaults={"path": ""})
206
+ @app.route("/<path:path>")
207
+ def catch_all(path):
208
+ return render_template("index.html")
209
+
210
+
211
+ class Dispatcher(object):
212
+ def __init__(self):
213
+ self.clients = []
214
+ for signal_name in SIGNALS:
215
+ signal(signal_name).connect(self.on_multivisor_event)
216
+
217
+ def add_listener(self, client):
218
+ self.clients.append(client)
219
+
220
+ def remove_listener(self, client):
221
+ self.clients.remove(client)
222
+
223
+ def on_multivisor_event(self, signal, payload):
224
+ data = json.dumps(dict(payload=payload, event=signal))
225
+ event = "data: {0}\n\n".format(data)
226
+ for client in self.clients:
227
+ client.put(event)
228
+
229
+
230
+ def set_secret_key():
231
+ """
232
+ In order to use flask sessions, secret_key must be set,
233
+ require "MULTIVISOR_SECRET_KEY" env variable only if
234
+ login and password is set in multivisor config
235
+ You can generate secret by invoking:
236
+ python -c 'import os; import binascii; print(binascii.hexlify(os.urandom(32)))'
237
+ """
238
+ if app.multivisor.use_authentication:
239
+ secret_key = os.environ.get("MULTIVISOR_SECRET_KEY")
240
+ if not secret_key:
241
+ raise Exception(
242
+ '"MULTIVISOR_SECRET_KEY" environmental variable must be set '
243
+ "when authentication is enabled"
244
+ )
245
+ app.secret_key = secret_key
246
+
247
+
248
+ @app.errorhandler(401)
249
+ def custom_401(error):
250
+ response_data = {"message": "Authenthication is required to access this endpoint"}
251
+ return Response(
252
+ json.dumps(response_data), 401, {"content-type": "application/json"}
253
+ )
254
+
255
+
256
+ def run_with_reloader_if_debug(func):
257
+ @functools.wraps(func)
258
+ def wrapper_login_required(*args, **kwargs):
259
+ if not app.debug:
260
+ return func(*args, **kwargs)
261
+ return run_simple(func, *args, **kwargs, use_reloader=True)
262
+
263
+ return wrapper_login_required
264
+
265
+
266
+ def get_parser(args):
267
+ import argparse
268
+
269
+ parser = argparse.ArgumentParser()
270
+ parser.add_argument(
271
+ "--bind", help="[host][:port] (default: *:22000)", default="*:22000"
272
+ )
273
+ parser.add_argument(
274
+ "-c",
275
+ help="configuration file",
276
+ dest="config_file",
277
+ default="/etc/multivisor.conf",
278
+ )
279
+ parser.add_argument(
280
+ "--log-level",
281
+ help="log level",
282
+ type=str,
283
+ default="INFO",
284
+ choices=["DEBUG", "INFO", "WARN", "ERROR"],
285
+ )
286
+ return parser
287
+
288
+
289
+ @run_with_reloader_if_debug
290
+ def main(args=None):
291
+ parser = get_parser(args)
292
+ options = parser.parse_args(args)
293
+
294
+ log_level = getattr(logging, options.log_level.upper())
295
+ log_fmt = "%(levelname)s %(asctime)-15s %(name)s: %(message)s"
296
+ logging.basicConfig(level=log_level, format=log_fmt)
297
+
298
+ if not os.path.exists(options.config_file):
299
+ parser.exit(
300
+ status=2, message="configuration file does not exist. Bailing out!\n"
301
+ )
302
+
303
+ bind = sanitize_url(options.bind, host="*", port=22000)["url"]
304
+
305
+ app.dispatcher = Dispatcher()
306
+ app.multivisor = Multivisor(options)
307
+
308
+ if app.multivisor.use_authentication:
309
+ secret_key = os.environ.get("MULTIVISOR_SECRET_KEY")
310
+ if not secret_key:
311
+ raise Exception(
312
+ '"MULTIVISOR_SECRET_KEY" environmental variable must be set '
313
+ "when authentication is enabled"
314
+ )
315
+ app.secret_key = secret_key
316
+
317
+ application = DebuggedApplication(app, evalex=True) if app.debug else app
318
+ http_server = WSGIServer(bind, application=application)
319
+ logging.info("Start accepting requests")
320
+ try:
321
+ http_server.serve_forever()
322
+ except KeyboardInterrupt:
323
+ log.info("Ctrl-C pressed. Bailing out")
324
+
325
+
326
+ if __name__ == "__main__":
327
+ main()
multivisor/signals.py ADDED
@@ -0,0 +1,5 @@
1
+ SIGNALS = [
2
+ "process_changed",
3
+ "supervisor_changed",
4
+ "notification",
5
+ ]
File without changes
@@ -0,0 +1,179 @@
1
+ import pytest
2
+
3
+ from tests.conftest import *
4
+ from tests.functions import assert_fields_in_object
5
+ import contextlib
6
+
7
+
8
+ @pytest.mark.usefixtures("supervisor_test001")
9
+ def test_supervisors_attr(multivisor_instance):
10
+ supervisors = multivisor_instance.supervisors
11
+ assert "test001" in supervisors
12
+
13
+
14
+ @pytest.mark.usefixtures("supervisor_test001")
15
+ def test_supervisor_info(multivisor_instance):
16
+ supervisor = multivisor_instance.get_supervisor("test001")
17
+ info = supervisor.read_info()
18
+ assert_fields_in_object(
19
+ [
20
+ "running",
21
+ "host",
22
+ "version",
23
+ "identification",
24
+ "name",
25
+ "url",
26
+ "supervisor_version",
27
+ "pid",
28
+ "processes",
29
+ "api_version",
30
+ ],
31
+ info,
32
+ )
33
+ assert info["running"]
34
+ assert info["host"] == "localhost"
35
+ assert len(info["processes"]) == 10
36
+ assert info["name"] == "test001"
37
+ assert info["identification"] == "supervisor"
38
+
39
+
40
+ @pytest.mark.usefixtures("supervisor_test001")
41
+ def test_supervisor_info_from_bytes(multivisor_instance):
42
+ supervisor = multivisor_instance.get_supervisor("test001")
43
+
44
+ @contextlib.contextmanager
45
+ def patched_getAllProcessInfo(s):
46
+ try:
47
+ getAllProcessInfo = s.server.getAllProcessInfo
48
+
49
+ def mockedAllProcessInfo():
50
+ processesInfo = getAllProcessInfo()
51
+ for info in processesInfo:
52
+ info[b"name"] = info.pop("name").encode("ascii")
53
+ info[b"description"] = info.pop("description").encode("ascii")
54
+ return processesInfo
55
+
56
+ s.server.getAllProcessInfo = mockedAllProcessInfo
57
+ yield
58
+ finally:
59
+ s.server.getAllProcessInfo = getAllProcessInfo
60
+
61
+ # Mock getAllProcessInfo with binary data
62
+ with patched_getAllProcessInfo(supervisor):
63
+ info = supervisor.read_info()
64
+ assert_fields_in_object(
65
+ [
66
+ "running",
67
+ "host",
68
+ "version",
69
+ "identification",
70
+ "name",
71
+ "url",
72
+ "supervisor_version",
73
+ "pid",
74
+ "processes",
75
+ "api_version",
76
+ ],
77
+ info,
78
+ )
79
+ assert info["running"]
80
+ assert info["host"] == "localhost"
81
+ assert len(info["processes"]) == 10
82
+ assert info["name"] == "test001"
83
+ assert info["identification"] == "supervisor"
84
+
85
+
86
+ @pytest.mark.usefixtures("supervisor_test001")
87
+ def test_processes_attr(multivisor_instance):
88
+ multivisor_instance.refresh() # processes are empty before calling this
89
+ processes = multivisor_instance.processes
90
+ assert len(processes) == 10
91
+ assert "test001:PLC:wcid00d" in processes
92
+ process = processes["test001:PLC:wcid00d"]
93
+ assert_fields_in_object(
94
+ [
95
+ "logfile",
96
+ "supervisor",
97
+ "description",
98
+ "state",
99
+ "pid",
100
+ "stderr_logfile",
101
+ "stop",
102
+ "host",
103
+ "statename",
104
+ "name",
105
+ "start",
106
+ "running",
107
+ "stdout_logfile",
108
+ "full_name",
109
+ "group",
110
+ "now",
111
+ "exitstatus",
112
+ "spawnerr",
113
+ "uid",
114
+ ],
115
+ process,
116
+ )
117
+ assert process["supervisor"] == "test001"
118
+ assert process["full_name"] == "PLC:wcid00d"
119
+ assert process["name"] == "wcid00d"
120
+ assert process["uid"] == "test001:PLC:wcid00d"
121
+ assert process["group"] == "PLC"
122
+ assert "tests/log/wcid00d.log" in process["logfile"]
123
+ assert "tests/log/wcid00d.log" in process["stdout_logfile"]
124
+ assert process["stderr_logfile"] == ""
125
+
126
+
127
+ @pytest.mark.usefixtures("supervisor_test001")
128
+ def test_get_process(multivisor_instance):
129
+ multivisor_instance.refresh() # processes are empty before calling this
130
+ uid = "test001:PLC:wcid00d"
131
+ process = multivisor_instance.get_process(uid)
132
+ assert process["supervisor"] == "test001"
133
+ assert process["full_name"] == "PLC:wcid00d"
134
+ assert process["name"] == "wcid00d"
135
+ assert process["uid"] == "test001:PLC:wcid00d"
136
+ assert process["group"] == "PLC"
137
+ assert "tests/log/wcid00d.log" in process["logfile"]
138
+ assert "tests/log/wcid00d.log" in process["stdout_logfile"]
139
+ assert process["stderr_logfile"] == ""
140
+
141
+
142
+ @pytest.mark.usefixtures("supervisor_test001")
143
+ def test_use_authentication(multivisor_instance):
144
+ assert not multivisor_instance.use_authentication
145
+
146
+
147
+ @pytest.mark.usefixtures("supervisor_test001")
148
+ def test_stop_process(multivisor_instance):
149
+ multivisor_instance.refresh() # processes are empty before calling this
150
+ uid = "test001:PLC:wcid00d"
151
+ process = multivisor_instance.get_process(uid)
152
+ print(process)
153
+ index, max_retries = 0, 10
154
+ while not process["running"]: # make sure process is running
155
+ multivisor_instance.refresh()
156
+ process = multivisor_instance.get_process(uid)
157
+ sleep(0.5)
158
+ if index == max_retries:
159
+ raise AssertionError("Process {} is not running".format(uid))
160
+ index += 1
161
+
162
+ multivisor_instance.stop_processes(uid)
163
+ multivisor_instance.refresh()
164
+ process = multivisor_instance.get_process(uid)
165
+ assert not process["running"]
166
+
167
+
168
+ @pytest.mark.usefixtures("supervisor_test001")
169
+ def test_restart_process(multivisor_instance):
170
+ multivisor_instance.refresh() # processes are empty before calling this
171
+ uid = "test001:PLC:wcid00d"
172
+ process = multivisor_instance.get_process(uid)
173
+ if process["running"]:
174
+ multivisor_instance.stop_processes(uid)
175
+
176
+ multivisor_instance.restart_processes(uid)
177
+ multivisor_instance.refresh()
178
+ process = multivisor_instance.get_process(uid)
179
+ assert process["running"]
multivisor/util.py ADDED
@@ -0,0 +1,75 @@
1
+ import re
2
+ import fnmatch
3
+
4
+ try:
5
+ from collections import abc
6
+ except ImportError:
7
+ import collections as abc
8
+
9
+ import six
10
+
11
+ _PROTO_RE_STR = "(?P<protocol>\w+)\://"
12
+ _HOST_RE_STR = "?P<host>([\w\-_]+\.)*[\w\-_]+|\*"
13
+ _PORT_RE_STR = "\:(?P<port>\d{1,5})"
14
+
15
+ URL_RE = re.compile(
16
+ "({protocol})?({host})?({port})?".format(
17
+ protocol=_PROTO_RE_STR, host=_HOST_RE_STR, port=_PORT_RE_STR
18
+ )
19
+ )
20
+
21
+
22
+ def sanitize_url(url, protocol=None, host=None, port=None):
23
+ match = URL_RE.match(url)
24
+ if match is None:
25
+ raise ValueError("Invalid URL: {!r}".format(url))
26
+ pars = match.groupdict()
27
+ _protocol, _host, _port = pars["protocol"], pars["host"], pars["port"]
28
+ protocol = protocol if _protocol is None else _protocol
29
+ host = host if _host is None else _host
30
+ port = port if _port is None else _port
31
+ protocol = "" if protocol is None else (protocol + "://")
32
+ port = "" if port is None else ":" + str(port)
33
+ return dict(
34
+ url="{}{}{}".format(protocol, host, port),
35
+ protocol=protocol,
36
+ host=host,
37
+ port=port,
38
+ )
39
+
40
+
41
+ def filter_patterns(names, patterns):
42
+ patterns = [
43
+ "*:{}".format(p) if ":" not in p and "*" not in p else p for p in patterns
44
+ ]
45
+ result = set()
46
+ sets = (fnmatch.filter(names, pattern) for pattern in patterns)
47
+ list(map(result.update, sets))
48
+ return result
49
+
50
+
51
+ def parse_dict(obj):
52
+ """Returns a copy of `obj` where bytes from key/values was replaced by str"""
53
+ decoded = {}
54
+ for k, v in obj.items():
55
+ if isinstance(k, bytes):
56
+ k = k.decode("utf-8")
57
+ if isinstance(v, bytes):
58
+ v = v.decode("utf-8")
59
+ decoded[k] = v
60
+ return decoded
61
+
62
+
63
+ def parse_obj(obj):
64
+ """Returns `obj` or a copy replacing recursively bytes by str
65
+
66
+ `obj` can be any objects, including list and dictionary"""
67
+ if isinstance(obj, bytes):
68
+ return obj.decode()
69
+ elif isinstance(obj, six.text_type):
70
+ return obj
71
+ elif isinstance(obj, abc.Mapping):
72
+ return {parse_obj(k): parse_obj(v) for k, v in obj.items()}
73
+ elif isinstance(obj, abc.Container):
74
+ return type(obj)(parse_obj(i) for i in obj)
75
+ return obj