canvas 0.3.0__py3-none-any.whl → 0.4.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.
Potentially problematic release.
This version of canvas might be problematic. Click here for more details.
- {canvas-0.3.0.dist-info → canvas-0.4.0.dist-info}/METADATA +2 -1
- canvas-0.4.0.dist-info/RECORD +218 -0
- canvas_cli/apps/emit/__init__.py +3 -0
- canvas_cli/apps/emit/emit.py +67 -0
- canvas_cli/apps/emit/event_fixtures/ALLERGY_INTOLERANCE_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/ALLERGY_INTOLERANCE_UPDATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/APPOINTMENT_CANCELED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/APPOINTMENT_CHECKED_IN.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/APPOINTMENT_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/APPOINTMENT_NO_SHOWED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/APPOINTMENT_RESCHEDULED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/APPOINTMENT_RESTORED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/APPOINTMENT_UPDATED.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__CONDITION_SELECTED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__POST_COMMIT.ndjson +3 -0
- canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__POST_ORIGINATE.ndjson +4 -0
- canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__POST_UPDATE.ndjson +5 -0
- canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__PRE_COMMIT.ndjson +3 -0
- canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__PRE_ORIGINATE.ndjson +4 -0
- canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__PRE_UPDATE.ndjson +5 -0
- canvas_cli/apps/emit/event_fixtures/BILLING_LINE_ITEM_CREATED.ndjson +3 -0
- canvas_cli/apps/emit/event_fixtures/BILLING_LINE_ITEM_UPDATED.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/CONDITION_ASSESSED.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/CONDITION_CREATED.ndjson +4 -0
- canvas_cli/apps/emit/event_fixtures/CONDITION_UPDATED.ndjson +5 -0
- canvas_cli/apps/emit/event_fixtures/CRON.ndjson +3 -0
- canvas_cli/apps/emit/event_fixtures/ENCOUNTER_CREATED.ndjson +3 -0
- canvas_cli/apps/emit/event_fixtures/ENCOUNTER_UPDATED.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/IMMUNIZATION_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/IMMUNIZATION_STATEMENT_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/IMMUNIZATION_STATEMENT_UPDATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/IMMUNIZATION_UPDATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/INTERVIEW_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/INTERVIEW_UPDATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/LAB_ORDER_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/LAB_ORDER_UPDATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_LIST_ITEM_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_LIST_ITEM_UPDATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__POST_COMMIT.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__POST_ORIGINATE.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__POST_UPDATE.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__PRE_COMMIT.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__PRE_ORIGINATE.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__PRE_UPDATE.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT__MEDICATION__POST_SEARCH.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/PATIENT_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/PATIENT_UPDATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/PLAN_COMMAND__POST_ORIGINATE.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/PLAN_COMMAND__PRE_ORIGINATE.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__POST_COMMIT.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__POST_ORIGINATE.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__POST_UPDATE.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__PRE_COMMIT.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__PRE_ORIGINATE.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__PRE_UPDATE.ndjson +2 -0
- canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE__QUESTIONNAIRE__POST_SEARCH.ndjson +4 -0
- canvas_cli/apps/emit/event_fixtures/TASK_COMMENT_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/TASK_CREATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/TASK_UPDATED.ndjson +1 -0
- canvas_cli/apps/emit/event_fixtures/VITAL_SIGN_CREATED.ndjson +14 -0
- canvas_cli/apps/emit/event_fixtures/VITAL_SIGN_UPDATED.ndjson +364 -0
- canvas_cli/apps/logs/logs.py +6 -6
- canvas_cli/apps/plugin/plugin.py +11 -7
- canvas_cli/apps/run_plugins/__init__.py +3 -0
- canvas_cli/apps/run_plugins/run_plugins.py +16 -0
- canvas_cli/main.py +8 -38
- canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/README.md +0 -1
- canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +1 -1
- canvas_cli/tests.py +12 -5
- canvas_cli/utils/context/context.py +2 -2
- canvas_cli/utils/context/tests.py +5 -4
- canvas_cli/utils/print/print.py +1 -1
- canvas_cli/utils/print/tests.py +2 -3
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +12 -0
- canvas_sdk/base.py +2 -1
- canvas_sdk/commands/base.py +25 -25
- canvas_sdk/commands/tests/protocol/tests.py +5 -3
- canvas_sdk/commands/tests/test_utils.py +8 -44
- canvas_sdk/commands/tests/unit/tests.py +3 -3
- canvas_sdk/data/client.py +1 -1
- canvas_sdk/effects/banner_alert/tests.py +12 -4
- canvas_sdk/effects/protocol_card/protocol_card.py +1 -1
- canvas_sdk/effects/protocol_card/tests.py +2 -2
- canvas_sdk/protocols/clinical_quality_measure.py +1 -0
- canvas_sdk/utils/http.py +2 -2
- canvas_sdk/v1/data/base.py +1 -1
- canvas_sdk/v1/data/command.py +27 -0
- canvas_sdk/v1/data/common.py +46 -0
- canvas_sdk/v1/data/device.py +44 -0
- canvas_sdk/v1/data/imaging.py +102 -0
- canvas_sdk/v1/data/lab.py +182 -10
- canvas_sdk/v1/data/observation.py +117 -0
- canvas_sdk/v1/data/patient.py +4 -1
- canvas_sdk/v1/data/questionnaire.py +4 -2
- canvas_sdk/value_set/tests/test_value_sets.py +9 -6
- canvas_sdk/value_set/v2022/intervention.py +0 -24
- canvas_sdk/value_set/value_set.py +24 -21
- plugin_runner/__init__.py +0 -0
- plugin_runner/authentication.py +48 -0
- plugin_runner/plugin_runner.py +389 -0
- plugin_runner/plugin_synchronizer.py +87 -0
- plugin_runner/sandbox.py +273 -0
- pubsub/__init__.py +0 -0
- pubsub/pubsub.py +38 -0
- canvas-0.3.0.dist-info/RECORD +0 -145
- {canvas-0.3.0.dist-info → canvas-0.4.0.dist-info}/WHEEL +0 -0
- {canvas-0.3.0.dist-info → canvas-0.4.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib.util
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from types import FrameType
|
|
12
|
+
from typing import Any, AsyncGenerator, Optional, TypedDict, cast
|
|
13
|
+
|
|
14
|
+
import grpc
|
|
15
|
+
import statsd
|
|
16
|
+
|
|
17
|
+
from canvas_generated.messages.plugins_pb2 import (
|
|
18
|
+
ReloadPluginsRequest,
|
|
19
|
+
ReloadPluginsResponse,
|
|
20
|
+
)
|
|
21
|
+
from canvas_generated.services.plugin_runner_pb2_grpc import (
|
|
22
|
+
PluginRunnerServicer,
|
|
23
|
+
add_PluginRunnerServicer_to_server,
|
|
24
|
+
)
|
|
25
|
+
from canvas_sdk.effects import Effect
|
|
26
|
+
from canvas_sdk.events import Event, EventResponse, EventType
|
|
27
|
+
from canvas_sdk.protocols import ClinicalQualityMeasure
|
|
28
|
+
from canvas_sdk.utils.stats import get_duration_ms, tags_to_line_protocol
|
|
29
|
+
from logger import log
|
|
30
|
+
from plugin_runner.authentication import token_for_plugin
|
|
31
|
+
from plugin_runner.plugin_synchronizer import publish_message
|
|
32
|
+
from plugin_runner.sandbox import Sandbox
|
|
33
|
+
|
|
34
|
+
ENV = os.getenv("ENV", "development")
|
|
35
|
+
|
|
36
|
+
IS_PRODUCTION = ENV == "production"
|
|
37
|
+
|
|
38
|
+
MANIFEST_FILE_NAME = "CANVAS_MANIFEST.json"
|
|
39
|
+
|
|
40
|
+
SECRETS_FILE_NAME = "SECRETS.json"
|
|
41
|
+
|
|
42
|
+
# specify a local plugin directory for development
|
|
43
|
+
PLUGIN_DIRECTORY = "/plugin-runner/custom-plugins" if IS_PRODUCTION else "./custom-plugins"
|
|
44
|
+
|
|
45
|
+
# when we import plugins we'll use the module name directly so we need to add the plugin
|
|
46
|
+
# directory to the path
|
|
47
|
+
sys.path.append(PLUGIN_DIRECTORY)
|
|
48
|
+
|
|
49
|
+
# a global dictionary of loaded plugins
|
|
50
|
+
# TODO: create typings here for the subkeys
|
|
51
|
+
LOADED_PLUGINS: dict = {}
|
|
52
|
+
|
|
53
|
+
# a global dictionary of events to protocol class names
|
|
54
|
+
EVENT_PROTOCOL_MAP: dict = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DataAccess(TypedDict):
|
|
58
|
+
"""DataAccess."""
|
|
59
|
+
|
|
60
|
+
event: str
|
|
61
|
+
read: list[str]
|
|
62
|
+
write: list[str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Protocol = TypedDict(
|
|
66
|
+
"Protocol",
|
|
67
|
+
{
|
|
68
|
+
"class": str,
|
|
69
|
+
"data_access": DataAccess,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Components(TypedDict):
|
|
75
|
+
"""Components."""
|
|
76
|
+
|
|
77
|
+
protocols: list[Protocol]
|
|
78
|
+
commands: list[dict]
|
|
79
|
+
content: list[dict]
|
|
80
|
+
effects: list[dict]
|
|
81
|
+
views: list[dict]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PluginManifest(TypedDict):
|
|
85
|
+
"""PluginManifest."""
|
|
86
|
+
|
|
87
|
+
sdk_version: str
|
|
88
|
+
plugin_version: str
|
|
89
|
+
name: str
|
|
90
|
+
description: str
|
|
91
|
+
components: Components
|
|
92
|
+
secrets: list[dict]
|
|
93
|
+
tags: dict[str, str]
|
|
94
|
+
references: list[str]
|
|
95
|
+
license: str
|
|
96
|
+
diagram: bool
|
|
97
|
+
readme: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PluginRunner(PluginRunnerServicer):
|
|
101
|
+
"""This process runs provided plugins that register interest in incoming events."""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self.statsd_client = statsd.StatsClient()
|
|
105
|
+
super().__init__()
|
|
106
|
+
|
|
107
|
+
sandbox: Sandbox
|
|
108
|
+
|
|
109
|
+
async def HandleEvent(
|
|
110
|
+
self, request: Event, context: Any
|
|
111
|
+
) -> AsyncGenerator[EventResponse, None]:
|
|
112
|
+
"""This is invoked when an event comes in."""
|
|
113
|
+
event_start_time = time.time()
|
|
114
|
+
event_type = request.type
|
|
115
|
+
event_name = EventType.Name(event_type)
|
|
116
|
+
relevant_plugins = EVENT_PROTOCOL_MAP.get(event_name, [])
|
|
117
|
+
|
|
118
|
+
if event_type in [EventType.PLUGIN_CREATED, EventType.PLUGIN_UPDATED]:
|
|
119
|
+
plugin_name = request.target
|
|
120
|
+
# filter only for the plugin(s) that were created/updated
|
|
121
|
+
relevant_plugins = [p for p in relevant_plugins if p.startswith(f"{plugin_name}:")]
|
|
122
|
+
|
|
123
|
+
effect_list = []
|
|
124
|
+
|
|
125
|
+
for plugin_name in relevant_plugins:
|
|
126
|
+
plugin = LOADED_PLUGINS[plugin_name]
|
|
127
|
+
protocol_class = plugin["class"]
|
|
128
|
+
base_plugin_name = plugin_name.split(":")[0]
|
|
129
|
+
|
|
130
|
+
secrets = plugin.get("secrets", {})
|
|
131
|
+
secrets["graphql_jwt"] = token_for_plugin(plugin_name=plugin_name, audience="home")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
protocol = protocol_class(request, secrets)
|
|
135
|
+
classname = (
|
|
136
|
+
protocol.__class__.__name__
|
|
137
|
+
if isinstance(protocol, ClinicalQualityMeasure)
|
|
138
|
+
else None
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
compute_start_time = time.time()
|
|
142
|
+
_effects = await asyncio.get_running_loop().run_in_executor(None, protocol.compute)
|
|
143
|
+
effects = [
|
|
144
|
+
Effect(
|
|
145
|
+
type=effect.type,
|
|
146
|
+
payload=effect.payload,
|
|
147
|
+
plugin_name=base_plugin_name,
|
|
148
|
+
classname=classname,
|
|
149
|
+
)
|
|
150
|
+
for effect in _effects
|
|
151
|
+
]
|
|
152
|
+
compute_duration = get_duration_ms(compute_start_time)
|
|
153
|
+
|
|
154
|
+
log.info(f"{plugin_name}.compute() completed ({compute_duration} ms)")
|
|
155
|
+
statsd_tags = tags_to_line_protocol({"plugin": plugin_name})
|
|
156
|
+
self.statsd_client.timing(
|
|
157
|
+
f"plugins.protocol_duration_ms,{statsd_tags}",
|
|
158
|
+
delta=compute_duration,
|
|
159
|
+
)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
for error_line_with_newlines in traceback.format_exception(e):
|
|
162
|
+
for error_line in error_line_with_newlines.split("\n"):
|
|
163
|
+
log.error(error_line)
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
effect_list += effects
|
|
167
|
+
|
|
168
|
+
event_duration = get_duration_ms(event_start_time)
|
|
169
|
+
|
|
170
|
+
# Don't log anything if a protocol didn't actually run.
|
|
171
|
+
if relevant_plugins:
|
|
172
|
+
log.info(f"Responded to Event {event_name} ({event_duration} ms)")
|
|
173
|
+
statsd_tags = tags_to_line_protocol({"event": event_name})
|
|
174
|
+
self.statsd_client.timing(
|
|
175
|
+
f"plugins.event_duration_ms,{statsd_tags}",
|
|
176
|
+
delta=event_duration,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
yield EventResponse(success=True, effects=effect_list)
|
|
180
|
+
|
|
181
|
+
async def ReloadPlugins(
|
|
182
|
+
self, request: ReloadPluginsRequest, context: Any
|
|
183
|
+
) -> AsyncGenerator[ReloadPluginsResponse, None]:
|
|
184
|
+
"""This is invoked when we need to reload plugins."""
|
|
185
|
+
try:
|
|
186
|
+
load_plugins()
|
|
187
|
+
publish_message({"action": "restart"})
|
|
188
|
+
except ImportError:
|
|
189
|
+
yield ReloadPluginsResponse(success=False)
|
|
190
|
+
else:
|
|
191
|
+
yield ReloadPluginsResponse(success=True)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def handle_hup_cb(_signum: int, _frame: Optional[FrameType]) -> None:
|
|
195
|
+
"""handle_hup_cb."""
|
|
196
|
+
log.info("Received SIGHUP, reloading plugins...")
|
|
197
|
+
load_plugins()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def sandbox_from_module_name(module_name: str) -> Any:
|
|
201
|
+
"""Sandbox the code execution."""
|
|
202
|
+
spec = importlib.util.find_spec(module_name)
|
|
203
|
+
|
|
204
|
+
if not spec or not spec.origin:
|
|
205
|
+
raise Exception(f'Could not load plugin "{module_name}"')
|
|
206
|
+
|
|
207
|
+
origin = pathlib.Path(spec.origin)
|
|
208
|
+
source_code = origin.read_text()
|
|
209
|
+
|
|
210
|
+
sandbox = Sandbox(source_code)
|
|
211
|
+
|
|
212
|
+
return sandbox.execute()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_or_reload_plugin(path: pathlib.Path) -> None:
|
|
216
|
+
"""Given a path, load or reload a plugin."""
|
|
217
|
+
log.info(f"Loading {path}")
|
|
218
|
+
|
|
219
|
+
manifest_file = path / MANIFEST_FILE_NAME
|
|
220
|
+
manifest_json_str = manifest_file.read_text()
|
|
221
|
+
|
|
222
|
+
# the name is the folder name underneath the plugins directory
|
|
223
|
+
name = path.name
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
manifest_json: PluginManifest = json.loads(manifest_json_str)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
log.error(f'Unable to load plugin "{name}": {e}')
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
secrets_file = path / SECRETS_FILE_NAME
|
|
232
|
+
|
|
233
|
+
secrets_json = {}
|
|
234
|
+
if secrets_file.exists():
|
|
235
|
+
try:
|
|
236
|
+
secrets_json = json.load(secrets_file.open())
|
|
237
|
+
except Exception as e:
|
|
238
|
+
log.error(f'Unable to load secrets for plugin "{name}": {str(e)}')
|
|
239
|
+
|
|
240
|
+
# TODO add existing schema validation from Michela here
|
|
241
|
+
try:
|
|
242
|
+
protocols = manifest_json["components"]["protocols"]
|
|
243
|
+
except Exception as e:
|
|
244
|
+
log.error(f'Unable to load plugin "{name}": {str(e)}')
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
for protocol in protocols:
|
|
248
|
+
# TODO add class colon validation to existing schema validation
|
|
249
|
+
# TODO when we encounter an exception here, disable the plugin in response
|
|
250
|
+
try:
|
|
251
|
+
protocol_module, protocol_class = protocol["class"].split(":")
|
|
252
|
+
name_and_class = f"{name}:{protocol_module}:{protocol_class}"
|
|
253
|
+
except ValueError:
|
|
254
|
+
log.error(f"Unable to parse class for plugin '{name}': '{protocol['class']}'")
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
if name_and_class in LOADED_PLUGINS:
|
|
259
|
+
log.info(f"Reloading plugin '{name_and_class}'")
|
|
260
|
+
|
|
261
|
+
result = sandbox_from_module_name(protocol_module)
|
|
262
|
+
|
|
263
|
+
LOADED_PLUGINS[name_and_class]["active"] = True
|
|
264
|
+
|
|
265
|
+
LOADED_PLUGINS[name_and_class]["class"] = result[protocol_class]
|
|
266
|
+
LOADED_PLUGINS[name_and_class]["sandbox"] = result
|
|
267
|
+
LOADED_PLUGINS[name_and_class]["secrets"] = secrets_json
|
|
268
|
+
else:
|
|
269
|
+
log.info(f"Loading plugin '{name_and_class}'")
|
|
270
|
+
|
|
271
|
+
result = sandbox_from_module_name(protocol_module)
|
|
272
|
+
|
|
273
|
+
LOADED_PLUGINS[name_and_class] = {
|
|
274
|
+
"active": True,
|
|
275
|
+
"class": result[protocol_class],
|
|
276
|
+
"sandbox": result,
|
|
277
|
+
"protocol": protocol,
|
|
278
|
+
"secrets": secrets_json,
|
|
279
|
+
}
|
|
280
|
+
except Exception as err:
|
|
281
|
+
log.error(f"Error importing module '{name_and_class}': {err}")
|
|
282
|
+
for error_line in traceback.format_exception(err):
|
|
283
|
+
log.error(error_line)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def refresh_event_type_map() -> None:
|
|
287
|
+
"""Ensure the event subscriptions are up to date."""
|
|
288
|
+
global EVENT_PROTOCOL_MAP
|
|
289
|
+
EVENT_PROTOCOL_MAP = defaultdict(list)
|
|
290
|
+
|
|
291
|
+
for name, plugin in LOADED_PLUGINS.items():
|
|
292
|
+
if hasattr(plugin["class"], "RESPONDS_TO"):
|
|
293
|
+
responds_to = plugin["class"].RESPONDS_TO
|
|
294
|
+
|
|
295
|
+
if isinstance(responds_to, str):
|
|
296
|
+
EVENT_PROTOCOL_MAP[responds_to].append(name)
|
|
297
|
+
elif isinstance(responds_to, list):
|
|
298
|
+
for event in responds_to:
|
|
299
|
+
EVENT_PROTOCOL_MAP[event].append(name)
|
|
300
|
+
else:
|
|
301
|
+
log.warning(f"Unknown RESPONDS_TO type: {type(responds_to)}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def load_plugins(specified_plugin_paths: list[str] | None = None) -> None:
|
|
305
|
+
"""Load the plugins."""
|
|
306
|
+
# first mark each plugin as inactive since we want to remove it from
|
|
307
|
+
# LOADED_PLUGINS if it no longer exists on disk
|
|
308
|
+
for plugin in LOADED_PLUGINS.values():
|
|
309
|
+
plugin["active"] = False
|
|
310
|
+
|
|
311
|
+
if specified_plugin_paths is not None:
|
|
312
|
+
# convert to Paths
|
|
313
|
+
plugin_paths = [pathlib.Path(name) for name in specified_plugin_paths]
|
|
314
|
+
|
|
315
|
+
for plugin_path in plugin_paths:
|
|
316
|
+
# when we import plugins we'll use the module name directly so we need to add the plugin
|
|
317
|
+
# directory to the path
|
|
318
|
+
path_to_append = pathlib.Path(".") / plugin_path.parent
|
|
319
|
+
sys.path.append(path_to_append.as_posix())
|
|
320
|
+
else:
|
|
321
|
+
candidates = os.listdir(PLUGIN_DIRECTORY)
|
|
322
|
+
|
|
323
|
+
# convert to Paths
|
|
324
|
+
plugin_paths = [pathlib.Path(os.path.join(PLUGIN_DIRECTORY, name)) for name in candidates]
|
|
325
|
+
|
|
326
|
+
# get all directories under the plugin directory
|
|
327
|
+
plugin_paths = [path for path in plugin_paths if path.is_dir()]
|
|
328
|
+
|
|
329
|
+
# filter to only the directories containing a manifest file
|
|
330
|
+
plugin_paths = [path for path in plugin_paths if (path / MANIFEST_FILE_NAME).exists()]
|
|
331
|
+
|
|
332
|
+
# load or reload each plugin
|
|
333
|
+
for plugin_path in plugin_paths:
|
|
334
|
+
load_or_reload_plugin(plugin_path)
|
|
335
|
+
|
|
336
|
+
# if a plugin has been uninstalled/disabled remove it from LOADED_PLUGINS
|
|
337
|
+
for name, plugin in LOADED_PLUGINS.copy().items():
|
|
338
|
+
if not plugin["active"]:
|
|
339
|
+
del LOADED_PLUGINS[name]
|
|
340
|
+
|
|
341
|
+
refresh_event_type_map()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
_cleanup_coroutines = []
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def serve(specified_plugin_paths: list[str] | None = None) -> None:
|
|
348
|
+
"""Run the server."""
|
|
349
|
+
port = "50051"
|
|
350
|
+
|
|
351
|
+
server = grpc.aio.server()
|
|
352
|
+
server.add_insecure_port("127.0.0.1:" + port)
|
|
353
|
+
|
|
354
|
+
add_PluginRunnerServicer_to_server(PluginRunner(), server)
|
|
355
|
+
|
|
356
|
+
log.info(f"Starting server, listening on port {port}")
|
|
357
|
+
|
|
358
|
+
load_plugins(specified_plugin_paths)
|
|
359
|
+
|
|
360
|
+
await server.start()
|
|
361
|
+
|
|
362
|
+
async def server_graceful_shutdown() -> None:
|
|
363
|
+
log.info("Starting graceful shutdown...")
|
|
364
|
+
await server.stop(5)
|
|
365
|
+
|
|
366
|
+
_cleanup_coroutines.append(server_graceful_shutdown())
|
|
367
|
+
|
|
368
|
+
await server.wait_for_termination()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def run_server(specified_plugin_paths: list[str] | None = None) -> None:
|
|
372
|
+
"""Run the server."""
|
|
373
|
+
loop = asyncio.new_event_loop()
|
|
374
|
+
|
|
375
|
+
asyncio.set_event_loop(loop)
|
|
376
|
+
|
|
377
|
+
signal.signal(signal.SIGHUP, handle_hup_cb)
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
loop.run_until_complete(serve(specified_plugin_paths))
|
|
381
|
+
except KeyboardInterrupt:
|
|
382
|
+
pass
|
|
383
|
+
finally:
|
|
384
|
+
loop.run_until_complete(*_cleanup_coroutines)
|
|
385
|
+
loop.close()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
run_server()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pickle
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from subprocess import STDOUT, CalledProcessError, check_output
|
|
7
|
+
|
|
8
|
+
import redis
|
|
9
|
+
|
|
10
|
+
APP_NAME = os.getenv("APP_NAME")
|
|
11
|
+
|
|
12
|
+
CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER")
|
|
13
|
+
PLUGINS_PUBSUB_CHANNEL = os.getenv("PLUGINS_PUBSUB_CHANNEL", default="plugins")
|
|
14
|
+
|
|
15
|
+
CHANNEL_NAME = f"{CUSTOMER_IDENTIFIER}:{PLUGINS_PUBSUB_CHANNEL}"
|
|
16
|
+
|
|
17
|
+
REDIS_ENDPOINT = os.getenv("REDIS_ENDPOINT", f"redis://{APP_NAME}-redis:6379")
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
CLIENT_ID = Path("/app/container-unique-id.txt").read_text()
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
CLIENT_ID = "non-unique"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_client() -> tuple[redis.Redis, redis.client.PubSub]:
|
|
26
|
+
"""Return a Redis client and pubsub object."""
|
|
27
|
+
client = redis.Redis.from_url(REDIS_ENDPOINT)
|
|
28
|
+
pubsub = client.pubsub()
|
|
29
|
+
|
|
30
|
+
return client, pubsub
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def publish_message(message: dict) -> None:
|
|
34
|
+
"""Publish a message to the pubsub channel."""
|
|
35
|
+
client, _ = get_client()
|
|
36
|
+
|
|
37
|
+
message_with_id = {**message, "client_id": CLIENT_ID}
|
|
38
|
+
|
|
39
|
+
client.publish(CHANNEL_NAME, pickle.dumps(message_with_id))
|
|
40
|
+
client.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> None:
|
|
44
|
+
"""Listen for messages on the pubsub channel and restart the plugin-runner."""
|
|
45
|
+
print("plugin-synchronizer: starting")
|
|
46
|
+
|
|
47
|
+
_, pubsub = get_client()
|
|
48
|
+
|
|
49
|
+
pubsub.psubscribe(CHANNEL_NAME)
|
|
50
|
+
|
|
51
|
+
for message in pubsub.listen():
|
|
52
|
+
if not message:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
message_type = message.get("type", "")
|
|
56
|
+
|
|
57
|
+
if message_type != "pmessage":
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
data = pickle.loads(message.get("data", pickle.dumps({})))
|
|
61
|
+
|
|
62
|
+
if "action" not in data or "client_id" not in data:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Don't respond to our own messages
|
|
66
|
+
if data["client_id"] == CLIENT_ID:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if data["action"] == "restart":
|
|
70
|
+
# Run the plugin installer process
|
|
71
|
+
try:
|
|
72
|
+
print("plugin-synchronizer: installing plugins")
|
|
73
|
+
check_output(["./manage.py", "install_plugins_v2"], cwd="/app", stderr=STDOUT)
|
|
74
|
+
except CalledProcessError as e:
|
|
75
|
+
print("plugin-synchronizer: `./manage.py install_plugins_v2` failed:", e)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
print("plugin-synchronizer: sending SIGHUP to plugin-runner")
|
|
79
|
+
check_output(
|
|
80
|
+
["circusctl", "signal", "plugin-runner", "1"], cwd="/app", stderr=STDOUT
|
|
81
|
+
)
|
|
82
|
+
except CalledProcessError as e:
|
|
83
|
+
print("plugin-synchronizer: `circusctl signal plugin-runner 1` failed:", e)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|