Skip to content

Commit 63241f4

Browse files
committed
Extension management!
1 parent 1871019 commit 63241f4

File tree

6 files changed

+376
-7
lines changed

6 files changed

+376
-7
lines changed

backend/workers/manage_extension.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""
2+
Manage a 4CAT extension
3+
"""
4+
import subprocess
5+
import requests
6+
import logging
7+
import zipfile
8+
import shutil
9+
import shlex
10+
import json
11+
import ural
12+
import os
13+
import re
14+
15+
from logging.handlers import RotatingFileHandler
16+
from pathlib import Path
17+
18+
from backend.lib.worker import BasicWorker
19+
from common.config_manager import config
20+
21+
22+
class ExtensionManipulator(BasicWorker):
23+
"""
24+
Manage 4CAT extensions
25+
26+
4CAT extensions are essentially git repositories. This worker can clone the
27+
relevant git repository or delete it and clean up after it.
28+
29+
This is done in a worker instead of in the front-end code because cloning
30+
a large git repository can take some time so it is best to do it
31+
asynchronously. This is also future-proof in that it is easy to add support
32+
for installation code etc here later.
33+
34+
Results are logged to a separate log file that can then be inspected in the
35+
web interface.
36+
"""
37+
type = "manage-extension"
38+
max_workers = 1
39+
40+
def work(self):
41+
"""
42+
Do something with extensions
43+
"""
44+
extension_reference = self.job.data["remote_id"]
45+
task = self.job.details.get("task")
46+
47+
# note that this is a databaseless config reader
48+
# since we only need it for file paths
49+
self.config = config
50+
51+
# this worker uses its own log file instead of the main 4CAT log
52+
# this is so that it is easier to monitor error messages about failed
53+
# installations etc and display those separately in e.g. the web
54+
# interface
55+
56+
log_file = Path(self.config.get("PATH_ROOT")).joinpath(self.config.get("PATH_LOGS")).joinpath("extensions.log")
57+
logger = logging.getLogger(self.type)
58+
if not logger.handlers:
59+
handler = RotatingFileHandler(log_file, backupCount=1, maxBytes=50000)
60+
handler.level = logging.INFO
61+
handler.setFormatter(logging.Formatter("%(asctime)-15s | %(levelname)s: %(message)s",
62+
"%d-%m-%Y %H:%M:%S"))
63+
logger.addHandler(handler)
64+
logger.level = logging.INFO
65+
self.extension_log = logger
66+
67+
if task == "install":
68+
self.install_extension(extension_reference)
69+
elif task == "uninstall":
70+
self.uninstall_extension(extension_reference)
71+
72+
self.job.finish()
73+
74+
def uninstall_extension(self, extension_name):
75+
"""
76+
Remove extension
77+
78+
Currently as simple as deleting the folder, but could add further
79+
cleaning up code later.
80+
81+
While an extension can define configuration settings, we do not
82+
explicitly remove these here. 4CAT has general cleanup code for
83+
unreferenced settings and it may be beneficial to keep them in case
84+
the extension is re-installed later.
85+
86+
:param str extension_name: ID of the extension (i.e. name of the
87+
folder it is in)
88+
"""
89+
extensions_root = self.config.get("PATH_ROOT").joinpath("extensions")
90+
target_folder = extensions_root.joinpath(extension_name)
91+
92+
if not target_folder.exists():
93+
return self.extension_log.error(f"Extension {extension_name} does not exist - cannot remove it.")
94+
95+
try:
96+
shutil.rmtree(target_folder)
97+
self.extension_log.info(f"Finished uninstalling extension {extension_name}.")
98+
except OSError as e:
99+
self.extension_log.error(f"Could not uninstall extension {extension_name}. There may be an issue with "
100+
f"file privileges, or the extension is installed via a symbolic link which 4CAT "
101+
f"cannot manipulate. The system error message was: '{e}'")
102+
103+
def install_extension(self, repository_reference, overwrite=False):
104+
"""
105+
Install a 4CAT extension
106+
107+
4CAT extensions can be installed from a git URL or a zip archive. In
108+
either case, the files are first put into a temporary folder, after
109+
which the manifest in that folder is read to complete installation.
110+
111+
:param str repository_reference: Git repository URL, or zip archive
112+
path.
113+
:param bool overwrite: Overwrite extension if one exists? Set to
114+
`true` to upgrade existing extensions (for example)
115+
"""
116+
if self.job.details.get("source") == "remote":
117+
extension_folder, extension_name = self.clone_from_url(repository_reference)
118+
else:
119+
extension_folder, extension_name = self.unpack_from_zip(repository_reference)
120+
121+
if not extension_name:
122+
return self.extension_log.error("The 4CAT extension could not be installed.")
123+
124+
# read manifest file
125+
manifest_file = extension_folder.joinpath("metadata.json")
126+
if not manifest_file.exists():
127+
shutil.rmtree(extension_folder)
128+
return self.extension_log.error(f"Manifest file of newly cloned 4CAT extension {repository_reference} does "
129+
f"not exist. Cannot install as a 4CAT extension.")
130+
else:
131+
try:
132+
with manifest_file.open() as infile:
133+
manifest_data = json.load(infile)
134+
except json.JSONDecodeError:
135+
shutil.rmtree(extension_folder)
136+
return self.extension_log.error(f"Manifest file of newly cloned 4CAT extension {repository_reference} "
137+
f"could not be parsed. Cannot install as a 4CAT extension.")
138+
139+
canonical_name = manifest_data.get("name", extension_name)
140+
canonical_id = manifest_data.get("id", extension_name)
141+
142+
canonical_folder = extension_folder.with_name(canonical_id)
143+
existing_name = canonical_id
144+
existing_version = "unknown"
145+
146+
if canonical_folder.exists():
147+
if canonical_folder.joinpath("metadata.json").exists():
148+
with canonical_folder.joinpath("metadata.json").open() as infile:
149+
try:
150+
existing_manifest = json.load(infile)
151+
existing_name = existing_manifest.get("name", canonical_id)
152+
existing_version = existing_manifest.get("version", "unknown")
153+
except json.JSONDecodeError:
154+
pass
155+
156+
if overwrite:
157+
self.extension_log.warning(f"Uninstalling existing 4CAT extension {existing_name} (version "
158+
f"{existing_version}.")
159+
shutil.rmtree(canonical_folder)
160+
else:
161+
return self.extension_log.error(f"An extension with ID {canonical_id} is already installed "
162+
f"({extension_name}, version {existing_version}). Cannot install "
163+
f"another one with the same ID - uninstall it first.")
164+
165+
extension_folder.rename(canonical_folder)
166+
version = f"version {manifest_data.get('version', 'unknown')}"
167+
self.extension_log.info(f"Finished installing extension {canonical_name} (version {version}) with ID "
168+
f"{canonical_id}.")
169+
170+
171+
def unpack_from_zip(self, archive_path):
172+
"""
173+
Unpack extension files from a zip archive
174+
175+
Pretty straightforward - Make a temporary folder and extract the zip
176+
archive's contents into it.
177+
178+
:param str archive_path: Path to the zip file to extract
179+
:return tuple: Tuple of folder and extension name, or `None, None` on
180+
failure.
181+
"""
182+
archive_path = Path(archive_path)
183+
if not archive_path.exists():
184+
return self.extension_log.error(f"Extension file does not exist at {archive_path} - cannot install."), None
185+
186+
extension_name = archive_path.stem
187+
extensions_root = self.config.get("PATH_ROOT").joinpath("extensions")
188+
temp_name = self.get_temporary_folder(extensions_root)
189+
try:
190+
with zipfile.ZipFile(archive_path, "r") as archive_file:
191+
archive_file.extractall(temp_name)
192+
except Exception as e:
193+
archive_path.unlink()
194+
return self.extension_log.error(f"Could not extract extension zip archive {archive_path.name}: {e}. Cannot "
195+
f"install."), None
196+
197+
return temp_name, extension_name
198+
199+
200+
def clone_from_url(self, repository_url):
201+
"""
202+
Clone the extension files from a git repository URL
203+
204+
:param str repository_url: Git repository URL to clone extension from
205+
:return tuple: Tuple of folder and extension name, or `None, None` on
206+
failure.
207+
"""
208+
# we only know how to install extensions from URLs for now
209+
if not ural.is_url(repository_url):
210+
return self.extension_log.error(f"Cannot install 4CAT extension - invalid repository url: "
211+
f"{repository_url}"), None
212+
213+
# normalize URL and extract name
214+
repository_url = repository_url.strip().split("#")[-1]
215+
if repository_url.endswith("/"):
216+
repository_url = repository_url[:-1]
217+
repository_url_name = re.sub(r"\.git$", "", repository_url.split("/")[-1].split("?")[0].lower())
218+
219+
try:
220+
test_url = requests.head(repository_url)
221+
if test_url.status_code >= 400:
222+
return self.extension_log.error(
223+
f"Cannot install 4CAT extension - the repository URL is unreachable (status code "
224+
f"{test_url.status_code})"), None
225+
except requests.RequestException as e:
226+
return self.extension_log.error(
227+
f"Cannot install 4CAT extension - the repository URL seems invalid or unreachable ({e})"), None
228+
229+
# ok, we have a valid URL that is reachable - try cloning from it
230+
extensions_root = self.config.get("PATH_ROOT").joinpath("extensions")
231+
os.chdir(extensions_root)
232+
233+
temp_name = self.get_temporary_folder(extensions_root)
234+
235+
extension_folder = extensions_root.joinpath(temp_name)
236+
clone_command = f"git clone {shlex.quote(repository_url)} {temp_name}"
237+
clone_outcome = subprocess.run(shlex.split(clone_command), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
238+
239+
cloned_correctly = True
240+
if clone_outcome.returncode != 0:
241+
cloned_correctly = False
242+
self.extension_log.info(clone_outcome.stdout.decode("utf-8"))
243+
self.extension_log.error(f"Could not clone 4CAT extension repository from {repository_url} - see log for "
244+
f"details.")
245+
246+
if not cloned_correctly:
247+
if extension_folder.exists():
248+
shutil.rmtree(extension_folder)
249+
return self.extension_log.error(f"4CAT extension {repository_url} was not installed."), None
250+
251+
return extension_folder, repository_url_name
252+
253+
254+
def get_temporary_folder(self, extensions_root):
255+
# clone into a temporary folder, which we will rename as needed
256+
# this is because the repository name is not necessarily the extension
257+
# name
258+
temp_base = "new-extension"
259+
temp_name = temp_base
260+
temp_index = 0
261+
while extensions_root.joinpath(temp_name).exists():
262+
temp_index += 1
263+
temp_name = f"{temp_base}-{temp_index}"
264+
265+
return extensions_root.joinpath(temp_name)

common/lib/config_definition.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@
149149
"help": "Can restart/upgrade",
150150
"tooltip": "Controls whether users can restart, upgrade, and manage extensions 4CAT via the Control Panel"
151151
},
152+
"privileges.admin.can_manage_extensions": {
153+
"type": UserInput.OPTION_TOGGLE,
154+
"default": False,
155+
"help": "Can manage extensions",
156+
"tooltip": "Controls whether users can install and uninstall 4CAT extensions via the Control Panel"
157+
},
152158
"privileges.can_upgrade_to_dev": {
153159
# this is NOT an admin privilege, because all admins automatically
154160
# get all admin privileges! users still need the above privilege

webtool/static/css/control-panel.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ article .stats-container h3:not(.blocktitle) {
260260
margin-right: 0.5em;
261261
}
262262

263+
.log-display.wrapped-log {
264+
white-space: pre-line;
265+
}
266+
263267
/**
264268
** Bulk dataset management
265269
*/

webtool/templates/controlpanel/extensions-list.html

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ <h2><span>4CAT Extensions</span></h2>
2626
<th>Extension</th>
2727
<th>Version</th>
2828
<th>Links</th>
29+
<th>Actions</th>
2930
</tr>
3031
{% if extensions %}
3132
{% for extension_id, extension in extensions.items() %}
@@ -41,15 +42,57 @@ <h2><span>4CAT Extensions</span></h2>
4142
aria-hidden="true"></i><span
4243
class="sr-only">Remote git repository</span></a>{% endif %}
4344
</td>
45+
<td>
46+
<form action="{{ url_for("uninstall_extension") }}" method="POST">
47+
<input type="hidden" name="extension-name" value="{{ extension_id }}">
48+
<button class="tooltip-trigger"aria-controls="tooltip-uninstall"><i class="fa fa-folder-minus" aria-hidden="true"></i> <span class="sr-only">Uninstall extension</span></button>
49+
</form>
50+
</td>
4451
</tr>
4552
{% endfor %}
4653
{% else %}
4754
<tr>
48-
<td colspan="3">No 4CAT extensions are installed.</td>
55+
<td colspan="4">No 4CAT extensions are installed.</td>
4956
</tr>
5057
{% endif %}
5158
</table>
59+
60+
<p role="tooltip" class="multiple" id="tooltip-uninstall" aria-hidden="true">Uninstall this
61+
extension</p>
5262
</div>
5363
</section>
64+
65+
<section>
66+
<h2><span>Install new extension</span></h2>
67+
<p>Install a new extension by providing <strong>either</strong> a Git repository URL or a zip archive with
68+
the extension files in it below. <strong>Note that extension code can basically do anything on the
69+
system 4CAT runs on - make sure to only install code you trust.</strong></p>
70+
<p>After installing, the extension will initially be disabled. You can enable and disable extensions via the
71+
<a href="{{ url_for("manipulate_settings") }}">4CAT settings panel</a>.</p>
72+
73+
<form action="{{ url_for("extensions_panel") }}" method="POST" class="wide" enctype="multipart/form-data">
74+
<div class="form-element{% if "extension-url" in incomplete %} missing{% endif %}">
75+
<label for="extension-url">Repository URL</label>
76+
<input type="text" id="extension-url" name="extension-url">
77+
</div>
78+
<div class="form-element{% if "extension-file" in incomplete %} missing{% endif %}">
79+
<label for="extension-file">Zip archive</label>
80+
<input type="file" id="extension-file" name="extension-file">
81+
</div>
82+
<div class="submit-container">
83+
<button>
84+
<i class="fa fa-folder-plus" aria-hidden="true"></i> Install
85+
</button>
86+
</div>
87+
</form>
88+
</section>
89+
90+
<section>
91+
<h2><span>Extension installation log</span></h2>
92+
<p>Displaying last 150 lines of the log file.</p>
93+
<pre id="extension-log" class="content-container log-display wrapped-log" data-source="{{ url_for("get_log", logfile="extensions") }}" data-interval="3">
94+
Loading log file...
95+
</pre>
96+
</section>
5497
</article>
5598
{% endblock %}

webtool/views/views_admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ def get_log(logfile):
735735
:param str logfile: 'backend' or 'stderr'
736736
:return:
737737
"""
738-
if logfile not in ("stderr", "backend", "import"):
738+
if logfile not in ("stderr", "backend", "import", "extensions"):
739739
return "Not Found", 404
740740

741741
if logfile == "backend":

0 commit comments

Comments
 (0)