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.

Files changed (108) hide show
  1. {canvas-0.3.0.dist-info → canvas-0.4.0.dist-info}/METADATA +2 -1
  2. canvas-0.4.0.dist-info/RECORD +218 -0
  3. canvas_cli/apps/emit/__init__.py +3 -0
  4. canvas_cli/apps/emit/emit.py +67 -0
  5. canvas_cli/apps/emit/event_fixtures/ALLERGY_INTOLERANCE_CREATED.ndjson +1 -0
  6. canvas_cli/apps/emit/event_fixtures/ALLERGY_INTOLERANCE_UPDATED.ndjson +1 -0
  7. canvas_cli/apps/emit/event_fixtures/APPOINTMENT_CANCELED.ndjson +1 -0
  8. canvas_cli/apps/emit/event_fixtures/APPOINTMENT_CHECKED_IN.ndjson +1 -0
  9. canvas_cli/apps/emit/event_fixtures/APPOINTMENT_CREATED.ndjson +1 -0
  10. canvas_cli/apps/emit/event_fixtures/APPOINTMENT_NO_SHOWED.ndjson +1 -0
  11. canvas_cli/apps/emit/event_fixtures/APPOINTMENT_RESCHEDULED.ndjson +1 -0
  12. canvas_cli/apps/emit/event_fixtures/APPOINTMENT_RESTORED.ndjson +1 -0
  13. canvas_cli/apps/emit/event_fixtures/APPOINTMENT_UPDATED.ndjson +2 -0
  14. canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__CONDITION_SELECTED.ndjson +1 -0
  15. canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__POST_COMMIT.ndjson +3 -0
  16. canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__POST_ORIGINATE.ndjson +4 -0
  17. canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__POST_UPDATE.ndjson +5 -0
  18. canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__PRE_COMMIT.ndjson +3 -0
  19. canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__PRE_ORIGINATE.ndjson +4 -0
  20. canvas_cli/apps/emit/event_fixtures/ASSESS_COMMAND__PRE_UPDATE.ndjson +5 -0
  21. canvas_cli/apps/emit/event_fixtures/BILLING_LINE_ITEM_CREATED.ndjson +3 -0
  22. canvas_cli/apps/emit/event_fixtures/BILLING_LINE_ITEM_UPDATED.ndjson +2 -0
  23. canvas_cli/apps/emit/event_fixtures/CONDITION_ASSESSED.ndjson +2 -0
  24. canvas_cli/apps/emit/event_fixtures/CONDITION_CREATED.ndjson +4 -0
  25. canvas_cli/apps/emit/event_fixtures/CONDITION_UPDATED.ndjson +5 -0
  26. canvas_cli/apps/emit/event_fixtures/CRON.ndjson +3 -0
  27. canvas_cli/apps/emit/event_fixtures/ENCOUNTER_CREATED.ndjson +3 -0
  28. canvas_cli/apps/emit/event_fixtures/ENCOUNTER_UPDATED.ndjson +2 -0
  29. canvas_cli/apps/emit/event_fixtures/IMMUNIZATION_CREATED.ndjson +1 -0
  30. canvas_cli/apps/emit/event_fixtures/IMMUNIZATION_STATEMENT_CREATED.ndjson +1 -0
  31. canvas_cli/apps/emit/event_fixtures/IMMUNIZATION_STATEMENT_UPDATED.ndjson +1 -0
  32. canvas_cli/apps/emit/event_fixtures/IMMUNIZATION_UPDATED.ndjson +1 -0
  33. canvas_cli/apps/emit/event_fixtures/INTERVIEW_CREATED.ndjson +1 -0
  34. canvas_cli/apps/emit/event_fixtures/INTERVIEW_UPDATED.ndjson +1 -0
  35. canvas_cli/apps/emit/event_fixtures/LAB_ORDER_CREATED.ndjson +1 -0
  36. canvas_cli/apps/emit/event_fixtures/LAB_ORDER_UPDATED.ndjson +1 -0
  37. canvas_cli/apps/emit/event_fixtures/MEDICATION_LIST_ITEM_CREATED.ndjson +1 -0
  38. canvas_cli/apps/emit/event_fixtures/MEDICATION_LIST_ITEM_UPDATED.ndjson +1 -0
  39. canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__POST_COMMIT.ndjson +1 -0
  40. canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__POST_ORIGINATE.ndjson +1 -0
  41. canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__POST_UPDATE.ndjson +2 -0
  42. canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__PRE_COMMIT.ndjson +1 -0
  43. canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__PRE_ORIGINATE.ndjson +1 -0
  44. canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT_COMMAND__PRE_UPDATE.ndjson +2 -0
  45. canvas_cli/apps/emit/event_fixtures/MEDICATION_STATEMENT__MEDICATION__POST_SEARCH.ndjson +2 -0
  46. canvas_cli/apps/emit/event_fixtures/PATIENT_CREATED.ndjson +1 -0
  47. canvas_cli/apps/emit/event_fixtures/PATIENT_UPDATED.ndjson +1 -0
  48. canvas_cli/apps/emit/event_fixtures/PLAN_COMMAND__POST_ORIGINATE.ndjson +1 -0
  49. canvas_cli/apps/emit/event_fixtures/PLAN_COMMAND__PRE_ORIGINATE.ndjson +1 -0
  50. canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__POST_COMMIT.ndjson +1 -0
  51. canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__POST_ORIGINATE.ndjson +1 -0
  52. canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__POST_UPDATE.ndjson +2 -0
  53. canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__PRE_COMMIT.ndjson +1 -0
  54. canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__PRE_ORIGINATE.ndjson +1 -0
  55. canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE_COMMAND__PRE_UPDATE.ndjson +2 -0
  56. canvas_cli/apps/emit/event_fixtures/QUESTIONNAIRE__QUESTIONNAIRE__POST_SEARCH.ndjson +4 -0
  57. canvas_cli/apps/emit/event_fixtures/TASK_COMMENT_CREATED.ndjson +1 -0
  58. canvas_cli/apps/emit/event_fixtures/TASK_CREATED.ndjson +1 -0
  59. canvas_cli/apps/emit/event_fixtures/TASK_UPDATED.ndjson +1 -0
  60. canvas_cli/apps/emit/event_fixtures/VITAL_SIGN_CREATED.ndjson +14 -0
  61. canvas_cli/apps/emit/event_fixtures/VITAL_SIGN_UPDATED.ndjson +364 -0
  62. canvas_cli/apps/logs/logs.py +6 -6
  63. canvas_cli/apps/plugin/plugin.py +11 -7
  64. canvas_cli/apps/run_plugins/__init__.py +3 -0
  65. canvas_cli/apps/run_plugins/run_plugins.py +16 -0
  66. canvas_cli/main.py +8 -38
  67. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/README.md +0 -1
  68. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +1 -1
  69. canvas_cli/tests.py +12 -5
  70. canvas_cli/utils/context/context.py +2 -2
  71. canvas_cli/utils/context/tests.py +5 -4
  72. canvas_cli/utils/print/print.py +1 -1
  73. canvas_cli/utils/print/tests.py +2 -3
  74. canvas_generated/messages/events_pb2.py +2 -2
  75. canvas_generated/messages/events_pb2.pyi +12 -0
  76. canvas_sdk/base.py +2 -1
  77. canvas_sdk/commands/base.py +25 -25
  78. canvas_sdk/commands/tests/protocol/tests.py +5 -3
  79. canvas_sdk/commands/tests/test_utils.py +8 -44
  80. canvas_sdk/commands/tests/unit/tests.py +3 -3
  81. canvas_sdk/data/client.py +1 -1
  82. canvas_sdk/effects/banner_alert/tests.py +12 -4
  83. canvas_sdk/effects/protocol_card/protocol_card.py +1 -1
  84. canvas_sdk/effects/protocol_card/tests.py +2 -2
  85. canvas_sdk/protocols/clinical_quality_measure.py +1 -0
  86. canvas_sdk/utils/http.py +2 -2
  87. canvas_sdk/v1/data/base.py +1 -1
  88. canvas_sdk/v1/data/command.py +27 -0
  89. canvas_sdk/v1/data/common.py +46 -0
  90. canvas_sdk/v1/data/device.py +44 -0
  91. canvas_sdk/v1/data/imaging.py +102 -0
  92. canvas_sdk/v1/data/lab.py +182 -10
  93. canvas_sdk/v1/data/observation.py +117 -0
  94. canvas_sdk/v1/data/patient.py +4 -1
  95. canvas_sdk/v1/data/questionnaire.py +4 -2
  96. canvas_sdk/value_set/tests/test_value_sets.py +9 -6
  97. canvas_sdk/value_set/v2022/intervention.py +0 -24
  98. canvas_sdk/value_set/value_set.py +24 -21
  99. plugin_runner/__init__.py +0 -0
  100. plugin_runner/authentication.py +48 -0
  101. plugin_runner/plugin_runner.py +389 -0
  102. plugin_runner/plugin_synchronizer.py +87 -0
  103. plugin_runner/sandbox.py +273 -0
  104. pubsub/__init__.py +0 -0
  105. pubsub/pubsub.py +38 -0
  106. canvas-0.3.0.dist-info/RECORD +0 -145
  107. {canvas-0.3.0.dist-info → canvas-0.4.0.dist-info}/WHEEL +0 -0
  108. {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()