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.
- multivisor/__init__.py +0 -0
- multivisor/client/__init__.py +0 -0
- multivisor/client/cli.py +29 -0
- multivisor/client/http.py +93 -0
- multivisor/client/repl.py +244 -0
- multivisor/client/util.py +58 -0
- multivisor/multivisor.py +470 -0
- multivisor/rpc.py +232 -0
- multivisor/server/__init__.py +0 -0
- multivisor/server/dist/index.html +1 -0
- multivisor/server/dist/static/css/app.2aff3580a128440bba89b94112292cb1.css +6 -0
- multivisor/server/dist/static/css/app.2aff3580a128440bba89b94112292cb1.css.map +1 -0
- multivisor/server/dist/static/js/app.52791f915c2f060b9cb1.js +2 -0
- multivisor/server/dist/static/js/app.52791f915c2f060b9cb1.js.map +1 -0
- multivisor/server/dist/static/js/manifest.2ae2e69a05c33dfc65f8.js +2 -0
- multivisor/server/dist/static/js/manifest.2ae2e69a05c33dfc65f8.js.map +1 -0
- multivisor/server/dist/static/js/vendor.1d02877727062a41e9fb.js +1319 -0
- multivisor/server/dist/static/js/vendor.1d02877727062a41e9fb.js.map +1 -0
- multivisor/server/rpc.py +156 -0
- multivisor/server/tests/__init__.py +0 -0
- multivisor/server/tests/conftest.py +1 -0
- multivisor/server/tests/test_web.py +194 -0
- multivisor/server/util.py +70 -0
- multivisor/server/web.py +327 -0
- multivisor/signals.py +5 -0
- multivisor/tests/__init__.py +0 -0
- multivisor/tests/test_multivisor.py +179 -0
- multivisor/util.py +75 -0
- multivisor-6.0.2.dist-info/METADATA +375 -0
- multivisor-6.0.2.dist-info/RECORD +34 -0
- multivisor-6.0.2.dist-info/WHEEL +5 -0
- multivisor-6.0.2.dist-info/entry_points.txt +4 -0
- multivisor-6.0.2.dist-info/licenses/LICENSE +674 -0
- multivisor-6.0.2.dist-info/top_level.txt +1 -0
multivisor/server/rpc.py
ADDED
@@ -0,0 +1,156 @@
|
|
1
|
+
from __future__ import print_function
|
2
|
+
|
3
|
+
import sys
|
4
|
+
import logging
|
5
|
+
import xmlrpc.client
|
6
|
+
|
7
|
+
from os import environ
|
8
|
+
from functools import partial
|
9
|
+
|
10
|
+
from gevent import spawn
|
11
|
+
from gevent.lock import RLock
|
12
|
+
from gevent.queue import Queue
|
13
|
+
from gevent.fileobject import FileObject
|
14
|
+
from zerorpc import stream, Server, LostRemote
|
15
|
+
from supervisor.childutils import getRPCInterface
|
16
|
+
|
17
|
+
|
18
|
+
READY = "READY\n"
|
19
|
+
ACKNOWLEDGED = "RESULT 2\nOK"
|
20
|
+
DEFAULT_BIND = "tcp://*:9002"
|
21
|
+
|
22
|
+
|
23
|
+
def signal(stream, msg):
|
24
|
+
stream.write(msg)
|
25
|
+
stream.flush()
|
26
|
+
|
27
|
+
|
28
|
+
def wait_for_event(stream):
|
29
|
+
header_line = stream.readline()
|
30
|
+
event = dict((x.split(":") for x in header_line.split()))
|
31
|
+
payload_str = stream.read(int(event["len"]))
|
32
|
+
event["payload"] = dict((x.split(":") for x in payload_str.split()))
|
33
|
+
return event
|
34
|
+
|
35
|
+
|
36
|
+
def event_producer_loop(dispatch):
|
37
|
+
istream = FileObject(sys.stdin)
|
38
|
+
ostream = FileObject(sys.stdout, mode='w')
|
39
|
+
while True:
|
40
|
+
signal(ostream, READY)
|
41
|
+
event = wait_for_event(istream)
|
42
|
+
dispatch(event)
|
43
|
+
signal(ostream, ACKNOWLEDGED)
|
44
|
+
|
45
|
+
|
46
|
+
def event_consumer_loop(queue, handler):
|
47
|
+
for event in queue:
|
48
|
+
try:
|
49
|
+
handler(event)
|
50
|
+
except:
|
51
|
+
logging.exception("Error processing %s", event)
|
52
|
+
|
53
|
+
|
54
|
+
get_rpc = partial(getRPCInterface, environ)
|
55
|
+
|
56
|
+
|
57
|
+
def build_method(supervisor, name):
|
58
|
+
subsystem_name, func_name = name.split(".", 1)
|
59
|
+
def method(*args):
|
60
|
+
subsystem = getattr(supervisor.rpc, subsystem_name)
|
61
|
+
with supervisor.lock:
|
62
|
+
return getattr(subsystem, func_name)(*args)
|
63
|
+
method.__name__ = func_name
|
64
|
+
return func_name, method
|
65
|
+
|
66
|
+
|
67
|
+
class Supervisor(object):
|
68
|
+
|
69
|
+
def __init__(self, xml_rpc):
|
70
|
+
self.event_channels = set()
|
71
|
+
self.lock = RLock()
|
72
|
+
self.rpc = xml_rpc
|
73
|
+
for name in self.rpc.system.listMethods():
|
74
|
+
setattr(self, *build_method(self, name))
|
75
|
+
|
76
|
+
@stream
|
77
|
+
def event_stream(self):
|
78
|
+
logging.info("client connected to stream")
|
79
|
+
channel = Queue()
|
80
|
+
self.event_channels.add(channel)
|
81
|
+
try:
|
82
|
+
yield "First event to trigger connection. Please ignore me!"
|
83
|
+
for event in channel:
|
84
|
+
yield event
|
85
|
+
except LostRemote as e:
|
86
|
+
logging.info("remote end of stream disconnected")
|
87
|
+
finally:
|
88
|
+
self.event_channels.remove(channel)
|
89
|
+
|
90
|
+
def publish_event(self, event):
|
91
|
+
name = event["eventname"]
|
92
|
+
if name.startswith("TICK"):
|
93
|
+
return
|
94
|
+
event = dict(event)
|
95
|
+
if name.startswith("PROCESS_STATE"):
|
96
|
+
payload = event["payload"]
|
97
|
+
pname = "{}:{}".format(payload["groupname"], payload["processname"])
|
98
|
+
logging.info("handling %s of %s", name, pname)
|
99
|
+
try:
|
100
|
+
payload["process"] = self.getProcessInfo(pname)
|
101
|
+
except xmlrpc.client.Fault:
|
102
|
+
# probably supervisor is shutting down
|
103
|
+
logging.warn("probably shutting down...")
|
104
|
+
return
|
105
|
+
elif not name.startswith("SUPERVISOR_STATE"):
|
106
|
+
logging.warning("ignored %s", name)
|
107
|
+
return
|
108
|
+
for channel in self.event_channels:
|
109
|
+
channel.put(event)
|
110
|
+
|
111
|
+
|
112
|
+
def run(xml_rpc, bind=DEFAULT_BIND):
|
113
|
+
channel = Queue()
|
114
|
+
supervisor = Supervisor(xml_rpc)
|
115
|
+
t1 = spawn(event_consumer_loop, channel, supervisor.publish_event)
|
116
|
+
t2 = spawn(event_producer_loop, channel.put)
|
117
|
+
server = Server(supervisor)
|
118
|
+
server.bind(bind)
|
119
|
+
server.run()
|
120
|
+
|
121
|
+
|
122
|
+
def main(args=None):
|
123
|
+
import gevent.monkey
|
124
|
+
|
125
|
+
gevent.monkey.patch_all(thread=False, sys=True)
|
126
|
+
|
127
|
+
import argparse
|
128
|
+
|
129
|
+
parser = argparse.ArgumentParser()
|
130
|
+
parser.add_argument("--bind", help="bind address", default=DEFAULT_BIND)
|
131
|
+
parser.add_argument(
|
132
|
+
"--log-level",
|
133
|
+
help="log level",
|
134
|
+
type=str,
|
135
|
+
default="INFO",
|
136
|
+
choices=["DEBUG", "INFO", "WARN", "ERROR"],
|
137
|
+
)
|
138
|
+
options = parser.parse_args(args)
|
139
|
+
|
140
|
+
log_level = getattr(logging, options.log_level.upper())
|
141
|
+
log_fmt = "%(levelname)s %(asctime)-15s %(name)s: %(message)s"
|
142
|
+
logging.basicConfig(level=log_level, format=log_fmt)
|
143
|
+
|
144
|
+
bind = options.bind
|
145
|
+
if "://" not in bind:
|
146
|
+
bind = "tcp://" + bind
|
147
|
+
try:
|
148
|
+
rpc = get_rpc()
|
149
|
+
except KeyError:
|
150
|
+
print("multivisor-rpc can only run as supervisor eventlistener", file=sys.stderr)
|
151
|
+
exit(1)
|
152
|
+
run(rpc, bind)
|
153
|
+
|
154
|
+
|
155
|
+
if __name__ == "__main__":
|
156
|
+
main()
|
File without changes
|
@@ -0,0 +1 @@
|
|
1
|
+
import pytest
|
@@ -0,0 +1,194 @@
|
|
1
|
+
import requests
|
2
|
+
|
3
|
+
from tests.functions import assert_fields_in_object
|
4
|
+
from tests.conftest import *
|
5
|
+
|
6
|
+
|
7
|
+
@pytest.mark.usefixtures("api_base_url")
|
8
|
+
def test_data_view(api_base_url):
|
9
|
+
url = "{}/data".format(api_base_url)
|
10
|
+
response = requests.get(url)
|
11
|
+
assert response.status_code == 200
|
12
|
+
data = response.json()
|
13
|
+
assert_fields_in_object(["name", "supervisors"], data)
|
14
|
+
assert "test001" in data["supervisors"]
|
15
|
+
supervisor = data["supervisors"]["test001"]
|
16
|
+
assert_fields_in_object(
|
17
|
+
[
|
18
|
+
"processes",
|
19
|
+
"name",
|
20
|
+
"url",
|
21
|
+
"pid",
|
22
|
+
"running",
|
23
|
+
"host",
|
24
|
+
"version",
|
25
|
+
"identification",
|
26
|
+
"supervisor_version",
|
27
|
+
"api_version",
|
28
|
+
],
|
29
|
+
supervisor,
|
30
|
+
)
|
31
|
+
|
32
|
+
assert supervisor["running"]
|
33
|
+
processes = supervisor["processes"]
|
34
|
+
for name, process in processes.items():
|
35
|
+
assert_fields_in_object(
|
36
|
+
[
|
37
|
+
"stderr_logfile",
|
38
|
+
"description",
|
39
|
+
"statename",
|
40
|
+
"pid",
|
41
|
+
"stdout_logfile",
|
42
|
+
"full_name",
|
43
|
+
"supervisor",
|
44
|
+
"logfile",
|
45
|
+
"exitstatus",
|
46
|
+
],
|
47
|
+
process,
|
48
|
+
)
|
49
|
+
|
50
|
+
|
51
|
+
@pytest.mark.usefixtures("api_base_url")
|
52
|
+
def test_config_view(api_base_url):
|
53
|
+
url = "{}/config/file".format(api_base_url)
|
54
|
+
response = requests.get(url)
|
55
|
+
assert response.status_code == 200
|
56
|
+
data = response.json()
|
57
|
+
assert "content" in data
|
58
|
+
|
59
|
+
|
60
|
+
@pytest.mark.usefixtures("api_base_url")
|
61
|
+
def test_list_processes_view(api_base_url):
|
62
|
+
url = "{}/process/list".format(api_base_url)
|
63
|
+
response = requests.get(url)
|
64
|
+
assert response.status_code == 200
|
65
|
+
data = response.json()
|
66
|
+
assert isinstance(data, list)
|
67
|
+
assert len(data) == 10
|
68
|
+
|
69
|
+
|
70
|
+
@pytest.mark.usefixtures("api_base_url")
|
71
|
+
def test_process_info_view(api_base_url):
|
72
|
+
uid = "test001:PLC:wcid00d"
|
73
|
+
url = "{}/process/info/{}".format(api_base_url, uid)
|
74
|
+
response = requests.get(url)
|
75
|
+
assert response.status_code == 200
|
76
|
+
process_data = response.json()
|
77
|
+
keys = [
|
78
|
+
u"logfile",
|
79
|
+
u"statename",
|
80
|
+
u"group",
|
81
|
+
u"description",
|
82
|
+
u"pid",
|
83
|
+
u"stderr_logfile",
|
84
|
+
u"stop",
|
85
|
+
u"running",
|
86
|
+
u"name",
|
87
|
+
u"start",
|
88
|
+
u"state",
|
89
|
+
u"spawnerr",
|
90
|
+
u"full_name",
|
91
|
+
u"host",
|
92
|
+
u"supervisor",
|
93
|
+
u"now",
|
94
|
+
u"exitstatus",
|
95
|
+
u"stdout_logfile",
|
96
|
+
u"uid",
|
97
|
+
]
|
98
|
+
|
99
|
+
assert_fields_in_object(keys, process_data)
|
100
|
+
assert process_data["uid"] == uid
|
101
|
+
assert process_data["supervisor"] == "test001"
|
102
|
+
assert process_data["group"] == "PLC"
|
103
|
+
assert process_data["name"] == "wcid00d"
|
104
|
+
|
105
|
+
|
106
|
+
@pytest.mark.usefixtures("api_base_url")
|
107
|
+
def test_supervisor_info_view(api_base_url):
|
108
|
+
uid = "test001"
|
109
|
+
url = "{}/supervisor/info/{}".format(api_base_url, uid)
|
110
|
+
response = requests.get(url)
|
111
|
+
assert response.status_code == 200
|
112
|
+
supervisor_data = response.json()
|
113
|
+
keys = [
|
114
|
+
u"processes",
|
115
|
+
u"name",
|
116
|
+
u"url",
|
117
|
+
u"pid",
|
118
|
+
u"running",
|
119
|
+
u"host",
|
120
|
+
u"version",
|
121
|
+
u"identification",
|
122
|
+
u"supervisor_version",
|
123
|
+
u"api_version",
|
124
|
+
]
|
125
|
+
|
126
|
+
assert_fields_in_object(keys, supervisor_data)
|
127
|
+
assert supervisor_data["name"] == uid
|
128
|
+
assert len(supervisor_data["processes"]) == 10
|
129
|
+
|
130
|
+
|
131
|
+
@pytest.mark.usefixtures("api_base_url")
|
132
|
+
def test_reload_view(api_base_url):
|
133
|
+
url = "{}/admin/reload".format(api_base_url)
|
134
|
+
response = requests.get(url)
|
135
|
+
assert response.status_code == 200
|
136
|
+
|
137
|
+
|
138
|
+
@pytest.mark.usefixtures("api_base_url")
|
139
|
+
def test_refresh_view(api_base_url):
|
140
|
+
url = "{}/refresh".format(api_base_url)
|
141
|
+
response = requests.get(url)
|
142
|
+
assert response.status_code == 200
|
143
|
+
|
144
|
+
|
145
|
+
@pytest.mark.usefixtures("api_base_url")
|
146
|
+
def test_stop_process_view(api_base_url, multivisor_instance):
|
147
|
+
multivisor_instance.refresh() # processes are empty before calling this
|
148
|
+
uid = "test001:PLC:wcid00d"
|
149
|
+
process = multivisor_instance.get_process(uid)
|
150
|
+
# assert process is currently running
|
151
|
+
index, max_retries = 0, 10
|
152
|
+
while not process["running"]:
|
153
|
+
multivisor_instance.refresh()
|
154
|
+
process = multivisor_instance.get_process(uid)
|
155
|
+
sleep(0.5)
|
156
|
+
if index == max_retries:
|
157
|
+
raise AssertionError("Process {} is not running".format(uid))
|
158
|
+
index += 1
|
159
|
+
|
160
|
+
# stop the process
|
161
|
+
url = "{}/process/stop".format(api_base_url)
|
162
|
+
response = requests.post(url, {"uid": uid})
|
163
|
+
assert response.status_code == 200
|
164
|
+
|
165
|
+
# assert process is stopped
|
166
|
+
index, max_retries = 0, 10
|
167
|
+
while process["running"]:
|
168
|
+
multivisor_instance.refresh()
|
169
|
+
process = multivisor_instance.get_process(uid)
|
170
|
+
sleep(0.5)
|
171
|
+
if index == max_retries:
|
172
|
+
raise AssertionError("Process {} is not stopped".format(uid))
|
173
|
+
index += 1
|
174
|
+
|
175
|
+
|
176
|
+
@pytest.mark.usefixtures("api_base_url")
|
177
|
+
def test_restart_process_view(api_base_url, multivisor_instance):
|
178
|
+
multivisor_instance.refresh() # processes are empty before calling this
|
179
|
+
uid = "test001:PLC:wcid00d"
|
180
|
+
process = multivisor_instance.get_process(uid)
|
181
|
+
# stop process if it's currently running
|
182
|
+
if process["running"]:
|
183
|
+
multivisor_instance.stop_processes(uid)
|
184
|
+
assert not process["running"]
|
185
|
+
|
186
|
+
# restart the process
|
187
|
+
url = "{}/process/restart".format(api_base_url)
|
188
|
+
response = requests.post(url, {"uid": uid})
|
189
|
+
assert response.status_code == 200
|
190
|
+
|
191
|
+
# assert process is restarted
|
192
|
+
multivisor_instance.refresh()
|
193
|
+
process = multivisor_instance.get_process(uid)
|
194
|
+
assert process["running"]
|
@@ -0,0 +1,70 @@
|
|
1
|
+
import hashlib
|
2
|
+
import functools
|
3
|
+
|
4
|
+
from flask import session, abort
|
5
|
+
|
6
|
+
|
7
|
+
def is_login_valid(app, username, password):
|
8
|
+
username = username.strip()
|
9
|
+
password = password.strip()
|
10
|
+
|
11
|
+
correct_username = app.multivisor.config["username"]
|
12
|
+
correct_password = app.multivisor.config["password"]
|
13
|
+
return constant_time_compare(username, correct_username) and constant_time_compare(
|
14
|
+
password, correct_password
|
15
|
+
)
|
16
|
+
|
17
|
+
|
18
|
+
def constant_time_compare(val1, val2):
|
19
|
+
"""
|
20
|
+
Returns True if the two strings are equal, False otherwise.
|
21
|
+
|
22
|
+
The time taken is independent of the number of characters that match.
|
23
|
+
|
24
|
+
For the sake of simplicity, this function executes in constant time only
|
25
|
+
when the two strings have the same length. It short-circuits when they
|
26
|
+
have different lengths.
|
27
|
+
|
28
|
+
Taken from Django Source Code
|
29
|
+
"""
|
30
|
+
val1 = hashlib.sha1(_safe_encode(val1)).hexdigest()
|
31
|
+
if val2.startswith("{SHA}"): # password can be specified as SHA-1 hash in config
|
32
|
+
val2 = val2.split("{SHA}")[1]
|
33
|
+
else:
|
34
|
+
val2 = hashlib.sha1(_safe_encode(val2)).hexdigest()
|
35
|
+
if len(val1) != len(val2):
|
36
|
+
return False
|
37
|
+
result = 0
|
38
|
+
for x, y in zip(val1, val2):
|
39
|
+
result |= ord(x) ^ ord(y)
|
40
|
+
return result == 0
|
41
|
+
|
42
|
+
|
43
|
+
def _safe_encode(data):
|
44
|
+
"""Safely encode @data string to utf-8"""
|
45
|
+
try:
|
46
|
+
result = data.encode("utf-8")
|
47
|
+
except (UnicodeDecodeError, UnicodeEncodeError, AttributeError):
|
48
|
+
result = data
|
49
|
+
return result
|
50
|
+
|
51
|
+
|
52
|
+
def login_required(app):
|
53
|
+
"""
|
54
|
+
Decorator to mark view as requiring being logged in
|
55
|
+
"""
|
56
|
+
|
57
|
+
def decorator(func):
|
58
|
+
@functools.wraps(func)
|
59
|
+
def wrapper_login_required(*args, **kwargs):
|
60
|
+
auth_on = app.multivisor.use_authentication
|
61
|
+
|
62
|
+
if not auth_on or "username" in session:
|
63
|
+
return func(*args, **kwargs)
|
64
|
+
|
65
|
+
# user not authenticated, return 401
|
66
|
+
abort(401)
|
67
|
+
|
68
|
+
return wrapper_login_required
|
69
|
+
|
70
|
+
return decorator
|