canvas 0.13.0__py3-none-any.whl → 0.13.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.
Potentially problematic release.
This version of canvas might be problematic. Click here for more details.
- {canvas-0.13.0.dist-info → canvas-0.13.2.dist-info}/METADATA +3 -1
- {canvas-0.13.0.dist-info → canvas-0.13.2.dist-info}/RECORD +9 -6
- plugin_runner/exceptions.py +14 -0
- plugin_runner/plugin_installer.py +208 -0
- plugin_runner/plugin_synchronizer.py +10 -7
- plugin_runner/tests/test_plugin_installer.py +118 -0
- settings.py +1 -1
- {canvas-0.13.0.dist-info → canvas-0.13.2.dist-info}/WHEEL +0 -0
- {canvas-0.13.0.dist-info → canvas-0.13.2.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: canvas
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.2
|
|
4
4
|
Summary: SDK to customize event-driven actions in your Canvas instance
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Canvas Team
|
|
@@ -10,6 +10,8 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: boto3 (>=1.35.88,<2.0.0)
|
|
14
|
+
Requires-Dist: boto3-stubs[s3] (>=1.35.88,<2.0.0)
|
|
13
15
|
Requires-Dist: cookiecutter
|
|
14
16
|
Requires-Dist: cron-converter (>=1.2.1,<2.0.0)
|
|
15
17
|
Requires-Dist: deprecation (>=2.1.0,<3.0.0)
|
|
@@ -232,8 +232,10 @@ logger/__init__.py,sha256=9o2iRCjzFEhfULgXvrrECRFK-4IslWJTqKKjTCEUbq8,61
|
|
|
232
232
|
logger/logger.py,sha256=axf7UffBJtETjwDCtmi1IaaJKsvcFj8zaLfouGsq68A,1847
|
|
233
233
|
plugin_runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
234
234
|
plugin_runner/authentication.py,sha256=SDPso2AogtLAV_H0LuMDp99IMZuF3oTq-Q_AXAvJ8uc,1116
|
|
235
|
+
plugin_runner/exceptions.py,sha256=YnRZiQVzbU3HrVlmEXLje_np99009YnhTRVHHyBCtqc,433
|
|
236
|
+
plugin_runner/plugin_installer.py,sha256=Ah3A1j6vO8wi4HPRYVlxF1w-GqbuTvLmDCQvLQK3Oy8,7020
|
|
235
237
|
plugin_runner/plugin_runner.py,sha256=at5BTolHnVWQci-GTUbvJsI-hsjeR9lDCnSz9OhIJ34,15296
|
|
236
|
-
plugin_runner/plugin_synchronizer.py,sha256=
|
|
238
|
+
plugin_runner/plugin_synchronizer.py,sha256=EwBpKopmArjXxsE9hWpvHRSaVjCq4mg8kXxK3hQMeCk,2684
|
|
237
239
|
plugin_runner/sandbox.py,sha256=JUbphdu1iY0rPU6DdMEjFA32BBsZXHUz1KguCYpmrug,9384
|
|
238
240
|
plugin_runner/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
239
241
|
plugin_runner/tests/data/plugins/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -267,12 +269,13 @@ plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/bas
|
|
|
267
269
|
plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
268
270
|
plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/my_protocol.py,sha256=V_6DPXsJaD12zGyKqGjYY2JFtKEk4E0eIAgBWax_MGc,629
|
|
269
271
|
plugin_runner/tests/test_application.py,sha256=ZirW14UGorfkQ_VSbnoKtmTvx6lHFS2EqndYNczIQfo,2391
|
|
272
|
+
plugin_runner/tests/test_plugin_installer.py,sha256=BETY7stpf_DZdSBLSSBc_NeAWcLU0m_cptG8C8_rk1Q,3937
|
|
270
273
|
plugin_runner/tests/test_plugin_runner.py,sha256=pj9C3-GNsTyUYNIkOiihmxAO67FQE1GU5BoM727SnxA,7539
|
|
271
274
|
plugin_runner/tests/test_sandbox.py,sha256=6Jw2Akcl1KQpXb01FxDvI_ZToJzOcpABwFkXK3fl8BU,3434
|
|
272
275
|
pubsub/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
273
276
|
pubsub/pubsub.py,sha256=pyTW0JU8mtaqiAV6g6xjZwel1CVy2EonPMU-_vkmhUM,1044
|
|
274
|
-
settings.py,sha256=
|
|
275
|
-
canvas-0.13.
|
|
276
|
-
canvas-0.13.
|
|
277
|
-
canvas-0.13.
|
|
278
|
-
canvas-0.13.
|
|
277
|
+
settings.py,sha256=UG0jV2Sht7ftEJjqEyLKn9fld0jAtblqO7oJdfjNYvA,2144
|
|
278
|
+
canvas-0.13.2.dist-info/METADATA,sha256=JikPMsbC7-iYOeMXZv-pSWyq06V0H3D1Des1-IFnfro,4793
|
|
279
|
+
canvas-0.13.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
280
|
+
canvas-0.13.2.dist-info/entry_points.txt,sha256=VSmSo1IZ3aEfL7enmLmlWSraS_IIkoXNVeyXzgRxFiY,46
|
|
281
|
+
canvas-0.13.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class PluginError(Exception):
|
|
2
|
+
"""An exception raised for plugin-related errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PluginValidationError(PluginError):
|
|
6
|
+
"""An exception raised when a plugin package is not valid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidPluginFormat(PluginValidationError):
|
|
10
|
+
"""An exception raised when the plugin file format is not supported."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PluginInstallationError(PluginError):
|
|
14
|
+
"""An exception raised when a plugin fails to install."""
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tarfile
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, TypedDict
|
|
10
|
+
from urllib import parse
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
import psycopg
|
|
14
|
+
from psycopg import Connection
|
|
15
|
+
from psycopg.rows import dict_row
|
|
16
|
+
|
|
17
|
+
import settings
|
|
18
|
+
from plugin_runner.exceptions import InvalidPluginFormat, PluginInstallationError
|
|
19
|
+
|
|
20
|
+
# Plugin "packages" include this prefix in the database record for the plugin and the S3 bucket key.
|
|
21
|
+
UPLOAD_TO_PREFIX = "plugins"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_database_dict_from_url() -> dict[str, Any]:
|
|
25
|
+
"""Creates a psycopg ready dictionary from the home-app database URL."""
|
|
26
|
+
parsed_url = parse.urlparse(os.getenv("DATABASE_URL"))
|
|
27
|
+
db_name = parsed_url.path[1:]
|
|
28
|
+
return {
|
|
29
|
+
"dbname": db_name,
|
|
30
|
+
"user": parsed_url.username,
|
|
31
|
+
"password": parsed_url.password,
|
|
32
|
+
"host": parsed_url.hostname,
|
|
33
|
+
"port": parsed_url.port,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_database_dict_from_env() -> dict[str, Any]:
|
|
38
|
+
"""Creates a psycopg ready dictionary from the environment variables."""
|
|
39
|
+
APP_NAME = os.getenv("APP_NAME")
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"dbname": APP_NAME,
|
|
43
|
+
"user": os.getenv("DB_USERNAME", "app"),
|
|
44
|
+
"password": os.getenv("DB_PASSWORD", "app"),
|
|
45
|
+
"host": os.getenv("DB_HOST", f"{APP_NAME}-db"),
|
|
46
|
+
"port": os.getenv("DB_PORT", "5432"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def open_database_connection() -> Connection:
|
|
51
|
+
"""Opens a psycopg connection to the home-app database."""
|
|
52
|
+
# When running within Aptible, use the database URL, otherwise pull from the environment variables.
|
|
53
|
+
if os.getenv("DATABASE_URL"):
|
|
54
|
+
database_dict = get_database_dict_from_url()
|
|
55
|
+
else:
|
|
56
|
+
database_dict = get_database_dict_from_env()
|
|
57
|
+
conn = psycopg.connect(**database_dict)
|
|
58
|
+
return conn
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PluginAttributes(TypedDict):
|
|
62
|
+
"""Attributes of a plugin."""
|
|
63
|
+
|
|
64
|
+
version: str
|
|
65
|
+
package: str
|
|
66
|
+
secrets: dict[str, str]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def enabled_plugins() -> dict[str, PluginAttributes]:
|
|
70
|
+
"""Returns a dictionary of enabled plugins and their attributes."""
|
|
71
|
+
conn = open_database_connection()
|
|
72
|
+
|
|
73
|
+
with conn.cursor(row_factory=dict_row) as cursor:
|
|
74
|
+
cursor.execute(
|
|
75
|
+
"select name, package, version, key, value from plugin_io_plugin p "
|
|
76
|
+
"left join plugin_io_pluginsecret s on p.id = s.plugin_id where is_enabled"
|
|
77
|
+
)
|
|
78
|
+
rows = cursor.fetchall()
|
|
79
|
+
plugins = _extract_rows_to_dict(rows)
|
|
80
|
+
|
|
81
|
+
return plugins
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_rows_to_dict(rows: list) -> dict[str, PluginAttributes]:
|
|
85
|
+
plugins = {}
|
|
86
|
+
for row in rows:
|
|
87
|
+
if row["name"] not in plugins:
|
|
88
|
+
plugins[row["name"]] = PluginAttributes(
|
|
89
|
+
version=row["version"],
|
|
90
|
+
package=row["package"],
|
|
91
|
+
secrets={row["key"]: row["value"]} if row["key"] else {},
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
plugins[row["name"]]["secrets"][row["key"]] = row["value"]
|
|
95
|
+
return plugins
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@contextmanager
|
|
99
|
+
def download_plugin(plugin_package: str) -> Generator:
|
|
100
|
+
"""Download the plugin package from the S3 bucket."""
|
|
101
|
+
s3 = boto3.client("s3")
|
|
102
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
103
|
+
prefix_dir = Path(temp_dir) / UPLOAD_TO_PREFIX
|
|
104
|
+
prefix_dir.mkdir() # create an intermediate directory reflecting the prefix
|
|
105
|
+
download_path = Path(temp_dir) / plugin_package
|
|
106
|
+
with open(download_path, "wb") as download_file:
|
|
107
|
+
s3.download_fileobj(
|
|
108
|
+
"canvas-client-media",
|
|
109
|
+
f"{settings.CUSTOMER_IDENTIFIER}/{plugin_package}",
|
|
110
|
+
download_file,
|
|
111
|
+
)
|
|
112
|
+
yield download_path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def install_plugin(plugin_name: str, attributes: PluginAttributes) -> None:
|
|
116
|
+
"""Install the given Plugin's package into the runtime."""
|
|
117
|
+
try:
|
|
118
|
+
print(f"Installing plugin '{plugin_name}'")
|
|
119
|
+
|
|
120
|
+
plugin_installation_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name
|
|
121
|
+
|
|
122
|
+
# if plugin exists, first uninstall it
|
|
123
|
+
if plugin_installation_path.exists():
|
|
124
|
+
uninstall_plugin(plugin_name)
|
|
125
|
+
|
|
126
|
+
with download_plugin(attributes["package"]) as plugin_file_path:
|
|
127
|
+
extract_plugin(plugin_file_path, plugin_installation_path)
|
|
128
|
+
|
|
129
|
+
install_plugin_secrets(plugin_name=plugin_name, secrets=attributes["secrets"])
|
|
130
|
+
except Exception as ex:
|
|
131
|
+
print(f"Failed to install plugin '{plugin_name}', version {attributes['version']}")
|
|
132
|
+
raise PluginInstallationError() from ex
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_plugin(plugin_file_path: Path, plugin_installation_path: Path) -> None:
|
|
136
|
+
"""Extract plugin in `file` to the given `path`."""
|
|
137
|
+
archive: tarfile.TarFile | None = None
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
if tarfile.is_tarfile(plugin_file_path):
|
|
141
|
+
try:
|
|
142
|
+
with open(plugin_file_path, "rb") as file:
|
|
143
|
+
archive = tarfile.TarFile.open(fileobj=file)
|
|
144
|
+
archive.extractall(plugin_installation_path, filter="data")
|
|
145
|
+
except tarfile.ReadError as ex:
|
|
146
|
+
print(f"Unreadable tar archive: '{plugin_file_path}'")
|
|
147
|
+
raise InvalidPluginFormat from ex
|
|
148
|
+
else:
|
|
149
|
+
print(f"Unsupported file format: '{plugin_file_path}'")
|
|
150
|
+
raise InvalidPluginFormat
|
|
151
|
+
finally:
|
|
152
|
+
if archive:
|
|
153
|
+
archive.close()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def install_plugin_secrets(plugin_name: str, secrets: dict[str, str]) -> None:
|
|
157
|
+
"""Write the plugin's secrets to disk in the package's directory."""
|
|
158
|
+
print(f"Writing plugin secrets for '{plugin_name}'")
|
|
159
|
+
|
|
160
|
+
secrets_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name / settings.SECRETS_FILE_NAME
|
|
161
|
+
|
|
162
|
+
# Did the plugin ship a secrets.json? TOO BAD, IT'S GONE NOW.
|
|
163
|
+
if Path(secrets_path).exists():
|
|
164
|
+
os.remove(secrets_path)
|
|
165
|
+
|
|
166
|
+
with open(str(secrets_path), "w") as f:
|
|
167
|
+
json.dump(secrets, f)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def disable_plugin(plugin_name: str) -> None:
|
|
171
|
+
"""Disable the given plugin."""
|
|
172
|
+
conn = open_database_connection()
|
|
173
|
+
conn.cursor().execute(
|
|
174
|
+
"update plugin_io_plugin set is_enabled = false where name = %s", (plugin_name,)
|
|
175
|
+
)
|
|
176
|
+
conn.commit()
|
|
177
|
+
conn.close()
|
|
178
|
+
|
|
179
|
+
uninstall_plugin(plugin_name)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def uninstall_plugin(plugin_name: str) -> None:
|
|
183
|
+
"""Remove the plugin from the filesystem."""
|
|
184
|
+
plugin_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name
|
|
185
|
+
|
|
186
|
+
if plugin_path.exists():
|
|
187
|
+
shutil.rmtree(plugin_path)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def install_plugins() -> None:
|
|
191
|
+
"""Install all enabled plugins."""
|
|
192
|
+
if Path(settings.PLUGIN_DIRECTORY).exists():
|
|
193
|
+
shutil.rmtree(settings.PLUGIN_DIRECTORY)
|
|
194
|
+
|
|
195
|
+
os.mkdir(settings.PLUGIN_DIRECTORY)
|
|
196
|
+
|
|
197
|
+
for plugin_name, attributes in enabled_plugins().items():
|
|
198
|
+
try:
|
|
199
|
+
print(f"Installing plugin '{plugin_name}', version {attributes['version']}")
|
|
200
|
+
install_plugin(plugin_name, attributes)
|
|
201
|
+
except PluginInstallationError:
|
|
202
|
+
disable_plugin(plugin_name)
|
|
203
|
+
print(
|
|
204
|
+
f"Installation failed for plugin '{plugin_name}', version {attributes['version']}. The plugin has been disabled"
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
return None
|
|
@@ -7,6 +7,8 @@ from subprocess import STDOUT, CalledProcessError, check_output
|
|
|
7
7
|
|
|
8
8
|
import redis
|
|
9
9
|
|
|
10
|
+
from plugin_runner.plugin_installer import install_plugins
|
|
11
|
+
|
|
10
12
|
APP_NAME = os.getenv("APP_NAME")
|
|
11
13
|
|
|
12
14
|
CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER")
|
|
@@ -43,6 +45,11 @@ def publish_message(message: dict) -> None:
|
|
|
43
45
|
def main() -> None:
|
|
44
46
|
"""Listen for messages on the pubsub channel and restart the plugin-runner."""
|
|
45
47
|
print("plugin-synchronizer: starting")
|
|
48
|
+
try:
|
|
49
|
+
print("plugin-synchronizer: installing plugins after web container start")
|
|
50
|
+
install_plugins()
|
|
51
|
+
except CalledProcessError as e:
|
|
52
|
+
print("plugin-synchronizer: `install_plugins` failed:", e)
|
|
46
53
|
|
|
47
54
|
_, pubsub = get_client()
|
|
48
55
|
|
|
@@ -62,17 +69,13 @@ def main() -> None:
|
|
|
62
69
|
if "action" not in data or "client_id" not in data:
|
|
63
70
|
return
|
|
64
71
|
|
|
65
|
-
# Don't respond to our own messages
|
|
66
|
-
if data["client_id"] == CLIENT_ID:
|
|
67
|
-
return
|
|
68
|
-
|
|
69
72
|
if data["action"] == "restart":
|
|
70
73
|
# Run the plugin installer process
|
|
71
74
|
try:
|
|
72
|
-
print("plugin-synchronizer: installing plugins")
|
|
73
|
-
|
|
75
|
+
print("plugin-synchronizer: installing plugins after receiving restart message")
|
|
76
|
+
install_plugins()
|
|
74
77
|
except CalledProcessError as e:
|
|
75
|
-
print("plugin-synchronizer:
|
|
78
|
+
print("plugin-synchronizer: `install_plugins` failed:", e)
|
|
76
79
|
|
|
77
80
|
try:
|
|
78
81
|
print("plugin-synchronizer: sending SIGHUP to plugin-runner")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tarfile
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
from pytest_mock import MockerFixture
|
|
8
|
+
|
|
9
|
+
import settings
|
|
10
|
+
from plugin_runner.plugin_installer import (
|
|
11
|
+
PluginAttributes,
|
|
12
|
+
_extract_rows_to_dict,
|
|
13
|
+
download_plugin,
|
|
14
|
+
install_plugins,
|
|
15
|
+
uninstall_plugin,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _create_tarball(name: str) -> Path:
|
|
20
|
+
# Create a temporary tarball file
|
|
21
|
+
temp_dir = tempfile.mkdtemp()
|
|
22
|
+
tarball_path = Path(temp_dir) / f"{name}.tar.gz"
|
|
23
|
+
|
|
24
|
+
# Add some files to the tarball
|
|
25
|
+
with tarfile.open(tarball_path, "w:gz") as tar:
|
|
26
|
+
for i in range(3):
|
|
27
|
+
file_path = Path(temp_dir) / f"file{i}.txt"
|
|
28
|
+
file_path.write_text(f"Content of file {i}")
|
|
29
|
+
tar.add(file_path, arcname=f"file{i}.txt")
|
|
30
|
+
|
|
31
|
+
# Return a Path handle to the tarball
|
|
32
|
+
return tarball_path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_extract_rows_to_dict() -> None:
|
|
36
|
+
"""Test that database rows can be extracted to a dictionary with secrets appropriately attributed to plugin."""
|
|
37
|
+
rows = [
|
|
38
|
+
{
|
|
39
|
+
"name": "plugin1",
|
|
40
|
+
"version": "1.0",
|
|
41
|
+
"package": "package1",
|
|
42
|
+
"key": "key1",
|
|
43
|
+
"value": "value1",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "plugin1",
|
|
47
|
+
"version": "1.0",
|
|
48
|
+
"package": "package1",
|
|
49
|
+
"key": "key2",
|
|
50
|
+
"value": "value2",
|
|
51
|
+
},
|
|
52
|
+
{"name": "plugin2", "version": "2.0", "package": "package2", "key": None, "value": None},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
expected_output = {
|
|
56
|
+
"plugin1": {
|
|
57
|
+
"version": "1.0",
|
|
58
|
+
"package": "package1",
|
|
59
|
+
"secrets": {"key1": "value1", "key2": "value2"},
|
|
60
|
+
},
|
|
61
|
+
"plugin2": {
|
|
62
|
+
"version": "2.0",
|
|
63
|
+
"package": "package2",
|
|
64
|
+
"secrets": {},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
result = _extract_rows_to_dict(rows)
|
|
69
|
+
assert result == expected_output
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_plugin_installation_from_tarball(mocker: MockerFixture) -> None:
|
|
73
|
+
"""Test that plugins can be installed from tarballs."""
|
|
74
|
+
mock_plugins = {
|
|
75
|
+
"plugin1": PluginAttributes(
|
|
76
|
+
version="1.0", package="plugins/plugin1.tar.gz", secrets={"key1": "value1"}
|
|
77
|
+
),
|
|
78
|
+
"plugin2": PluginAttributes(
|
|
79
|
+
version="1.0", package="plugins/plugin2.tar", secrets={"key2": "value2"}
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
tarball_1 = _create_tarball("plugin1")
|
|
84
|
+
tarball_2 = _create_tarball("plugin2")
|
|
85
|
+
|
|
86
|
+
mocker.patch("plugin_runner.plugin_installer.enabled_plugins", return_value=mock_plugins)
|
|
87
|
+
mocker.patch(
|
|
88
|
+
"plugin_runner.plugin_installer.download_plugin", side_effect=[tarball_1, tarball_2]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
install_plugins()
|
|
92
|
+
assert Path("plugin_runner/tests/data/plugins/plugin1").exists()
|
|
93
|
+
assert Path("plugin_runner/tests/data/plugins/plugin1/SECRETS.json").exists()
|
|
94
|
+
with open("plugin_runner/tests/data/plugins/plugin1/SECRETS.json") as f:
|
|
95
|
+
assert json.load(f) == mock_plugins["plugin1"]["secrets"]
|
|
96
|
+
assert Path("plugin_runner/tests/data/plugins/plugin2").exists()
|
|
97
|
+
assert Path("plugin_runner/tests/data/plugins/plugin2/SECRETS.json").exists()
|
|
98
|
+
with open("plugin_runner/tests/data/plugins/plugin2/SECRETS.json") as f:
|
|
99
|
+
assert json.load(f) == mock_plugins["plugin2"]["secrets"]
|
|
100
|
+
|
|
101
|
+
uninstall_plugin("plugin1")
|
|
102
|
+
uninstall_plugin("plugin2")
|
|
103
|
+
assert not Path("plugin_runner/tests/data/plugins/plugin1").exists()
|
|
104
|
+
assert not Path("plugin_runner/tests/data/plugins/plugin2").exists()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_download(mocker: MockerFixture) -> None:
|
|
108
|
+
"""Test that the plugin package can be written to disk, mocking out S3."""
|
|
109
|
+
mock_s3_client = MagicMock()
|
|
110
|
+
mocker.patch("boto3.client", return_value=mock_s3_client)
|
|
111
|
+
|
|
112
|
+
plugin_package = "plugins/plugin1.tar.gz"
|
|
113
|
+
with download_plugin(plugin_package) as plugin_path:
|
|
114
|
+
assert plugin_path.exists()
|
|
115
|
+
|
|
116
|
+
mock_s3_client.download_fileobj.assert_called_once_with(
|
|
117
|
+
"canvas-client-media", f"{settings.CUSTOMER_IDENTIFIER}/{plugin_package}", mocker.ANY
|
|
118
|
+
)
|
settings.py
CHANGED
|
@@ -11,7 +11,7 @@ load_dotenv()
|
|
|
11
11
|
ENV = os.getenv("ENV", "development")
|
|
12
12
|
IS_PRODUCTION = ENV == "production"
|
|
13
13
|
IS_TESTING = env_to_bool("IS_TESTING", "pytest" in sys.argv[0] or sys.argv[0] == "-c")
|
|
14
|
-
|
|
14
|
+
CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER", "local")
|
|
15
15
|
|
|
16
16
|
INTEGRATION_TEST_URL = os.getenv("INTEGRATION_TEST_URL")
|
|
17
17
|
INTEGRATION_TEST_CLIENT_ID = os.getenv("INTEGRATION_TEST_CLIENT_ID")
|
|
File without changes
|
|
File without changes
|