-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathinstallation.py
More file actions
275 lines (212 loc) · 8.4 KB
/
installation.py
File metadata and controls
275 lines (212 loc) · 8.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
import json
import os
import shutil
import tarfile
import tempfile
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from typing import TypedDict, cast
from urllib import parse
import psycopg
import requests
import sentry_sdk
from psycopg import Connection
from psycopg.rows import dict_row
from logger import log
from plugin_runner.aws_headers import aws_sig_v4_headers
from plugin_runner.exceptions import (
InvalidPluginFormat,
PluginInstallationError,
PluginUninstallationError,
)
from settings import (
AWS_ACCESS_KEY_ID,
AWS_REGION,
AWS_SECRET_ACCESS_KEY,
CUSTOMER_IDENTIFIER,
MEDIA_S3_BUCKET_NAME,
PLUGIN_DIRECTORY,
SECRETS_FILE_NAME,
)
# Plugin "packages" include this prefix in the database record for the plugin and the S3 bucket key.
UPLOAD_TO_PREFIX = "plugins"
def open_database_connection() -> Connection:
"""Opens a psycopg connection to the home-app database.
When running within Aptible, use the database URL, otherwise pull from
the environment variables.
"""
database_url = os.getenv("CANVAS_PLUGINS_BOUNCER_DATABASE_URL") or os.getenv("DATABASE_URL")
if database_url:
parsed_url = parse.urlparse(database_url)
return psycopg.connect(
dbname=parsed_url.path[1:],
user=cast(str, parsed_url.username),
password=cast(str, parsed_url.password),
host=cast(str, parsed_url.hostname),
port=parsed_url.port,
)
APP_NAME = os.getenv("APP_NAME")
return psycopg.connect(
dbname=APP_NAME,
user=os.getenv("DB_USERNAME", "app"),
password=os.getenv("DB_PASSWORD", "app"),
host=os.getenv("DB_HOST", f"{APP_NAME}-db"),
port=os.getenv("DB_PORT", "5432"),
)
class PluginAttributes(TypedDict):
"""Attributes of a plugin."""
version: str
package: str
secrets: dict[str, str]
def enabled_plugins(plugin_names: list[str] | None = None) -> dict[str, PluginAttributes]:
"""Returns a dictionary of enabled plugins and their attributes.
If `plugin_names` is provided, only returns those plugins (if enabled).
"""
conn = open_database_connection()
with conn.cursor(row_factory=dict_row) as cursor:
base_query = (
"SELECT name, package, version, key, value "
"FROM plugin_io_plugin p "
"LEFT JOIN plugin_io_pluginsecret s ON p.id = s.plugin_id "
"WHERE is_enabled"
)
params = []
if plugin_names:
placeholders = ",".join(["%s"] * len(plugin_names))
base_query += f" AND name IN ({placeholders})"
params.extend(plugin_names)
cursor.execute(base_query, params)
rows = cursor.fetchall()
plugins = _extract_rows_to_dict(rows)
return plugins
def _extract_rows_to_dict(rows: list) -> dict[str, PluginAttributes]:
plugins = {}
for row in rows:
if row["name"] not in plugins:
plugins[row["name"]] = PluginAttributes(
version=row["version"],
package=row["package"],
secrets={row["key"]: row["value"]} if row["key"] else {},
)
else:
plugins[row["name"]]["secrets"][row["key"]] = row["value"]
return plugins
@contextmanager
def download_plugin(plugin_package: str) -> Generator[Path, None, None]:
"""Download the plugin package from the S3 bucket."""
method = "GET"
host = f"s3-{AWS_REGION}.amazonaws.com"
bucket = MEDIA_S3_BUCKET_NAME
customer_identifier = CUSTOMER_IDENTIFIER
path = f"/{bucket}/{customer_identifier}/{plugin_package}"
payload = b"This is required for the AWS headers because it is part of the signature"
pre_auth_headers: dict[str, str] = {}
query: dict[str, str] = {}
headers = aws_sig_v4_headers(
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
pre_auth_headers,
"s3",
AWS_REGION,
host,
method,
path,
query,
payload,
)
with tempfile.TemporaryDirectory() as temp_dir:
prefix_dir = Path(temp_dir) / UPLOAD_TO_PREFIX
prefix_dir.mkdir() # create an intermediate directory reflecting the prefix
download_path = Path(temp_dir) / plugin_package
with open(download_path, "wb") as download_file:
response = requests.request(method=method, url=f"https://{host}{path}", headers=headers)
response.raise_for_status()
download_file.write(response.content)
yield download_path
def install_plugin(plugin_name: str, attributes: PluginAttributes) -> None:
"""Install the given Plugin's package into the runtime."""
try:
log.info(f'Installing plugin "{plugin_name}", version {attributes["version"]}')
plugin_installation_path = Path(PLUGIN_DIRECTORY) / plugin_name
# if plugin exists, first uninstall it
if plugin_installation_path.exists():
uninstall_plugin(plugin_name)
with download_plugin(attributes["package"]) as plugin_file_path:
extract_plugin(plugin_file_path, plugin_installation_path)
install_plugin_secrets(plugin_name=plugin_name, secrets=attributes["secrets"])
except Exception as e:
log.exception(f'Failed to install plugin "{plugin_name}", version {attributes["version"]}')
sentry_sdk.capture_exception(e)
raise PluginInstallationError() from e
def extract_plugin(plugin_file_path: Path, plugin_installation_path: Path) -> None:
"""Extract plugin in `file` to the given `path`."""
log.info(f'Extracting plugin at "{plugin_file_path}"')
archive: tarfile.TarFile | None = None
try:
if tarfile.is_tarfile(plugin_file_path):
try:
with open(plugin_file_path, "rb") as file:
archive = tarfile.TarFile.open(fileobj=file)
archive.extractall(plugin_installation_path, filter="data")
except tarfile.ReadError as e:
log.exception(f"Unreadable tar archive: '{plugin_file_path}'")
sentry_sdk.capture_exception(e)
raise InvalidPluginFormat from e
else:
log.error(f"Unsupported file format: '{plugin_file_path}'")
raise InvalidPluginFormat
finally:
if archive:
archive.close()
def install_plugin_secrets(plugin_name: str, secrets: dict[str, str]) -> None:
"""Write the plugin's secrets to disk in the package's directory."""
log.info(f"Writing plugin secrets for '{plugin_name}'")
secrets_path = Path(PLUGIN_DIRECTORY) / plugin_name / SECRETS_FILE_NAME
# Did the plugin ship a secrets.json? TOO BAD, IT'S GONE NOW.
if Path(secrets_path).exists():
os.remove(secrets_path)
with open(str(secrets_path), "w") as f:
json.dump(secrets, f)
def disable_plugin(plugin_name: str) -> None:
"""Disable the given plugin."""
conn = open_database_connection()
conn.cursor().execute(
"UPDATE plugin_io_plugin SET is_enabled = false WHERE name = %s", (plugin_name,)
)
conn.commit()
conn.close()
uninstall_plugin(plugin_name)
def uninstall_plugin(plugin_name: str) -> None:
"""Remove the plugin from the filesystem."""
try:
log.info(f'Uninstalling plugin "{plugin_name}"')
plugin_path = Path(PLUGIN_DIRECTORY) / plugin_name
if plugin_path.exists():
shutil.rmtree(plugin_path)
except Exception as e:
raise PluginUninstallationError() from e
def install_plugins() -> None:
"""Install all enabled plugins."""
log.info("Installing plugins")
try:
plugins_dir = Path(PLUGIN_DIRECTORY).resolve()
if plugins_dir.exists():
shutil.rmtree(plugins_dir.as_posix())
plugins_dir.mkdir(parents=False, exist_ok=True)
except Exception as e:
raise PluginInstallationError(
f'Failed to reset plugin directory "{PLUGIN_DIRECTORY}": {e}"'
) from e
for plugin_name, attributes in enabled_plugins().items():
try:
install_plugin(plugin_name, attributes)
except PluginInstallationError as e:
disable_plugin(plugin_name)
log.error(
f'Installation failed for plugin "{plugin_name}", version {attributes["version"]};'
" the plugin has been disabled"
)
sentry_sdk.capture_exception(e)
continue
return None