stackamole-xblock 9.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.
- fake/common/djangoapps/student/__init__.py +0 -0
- fake/common/djangoapps/student/models.py +15 -0
- fake/course/__init__.py +0 -0
- requirements/base.txt +25 -0
- requirements/flake8.txt +1 -0
- requirements/setup.txt +3 -0
- requirements/test.txt +19 -0
- samples/hot/sample-template.yaml +427 -0
- src/pip-delete-this-directory.txt +5 -0
- stackamole/__init__.py +8 -0
- stackamole/admin.py +130 -0
- stackamole/common.py +423 -0
- stackamole/jobs.py +211 -0
- stackamole/locale/config.yaml +14 -0
- stackamole/locale/de_DE/LC_MESSAGES/django.po +109 -0
- stackamole/locale/de_DE/LC_MESSAGES/djangojs.po +68 -0
- stackamole/locale/el/LC_MESSAGES/django.po +113 -0
- stackamole/locale/el/LC_MESSAGES/djangojs.po +77 -0
- stackamole/locale/en/LC_MESSAGES/django.po +107 -0
- stackamole/locale/en/LC_MESSAGES/djangojs.po +68 -0
- stackamole/locale/es_419/LC_MESSAGES/django.po +109 -0
- stackamole/locale/es_419/LC_MESSAGES/djangojs.po +68 -0
- stackamole/locale/et_EE/LC_MESSAGES/django.po +110 -0
- stackamole/locale/et_EE/LC_MESSAGES/djangojs.po +68 -0
- stackamole/locale/hi/LC_MESSAGES/django.po +111 -0
- stackamole/locale/hi/LC_MESSAGES/djangojs.po +72 -0
- stackamole/management/__init__.py +0 -0
- stackamole/management/commands/__init__.py +0 -0
- stackamole/management/commands/reaper.py +21 -0
- stackamole/management/commands/suspender.py +20 -0
- stackamole/migrations/0001_initial.py +43 -0
- stackamole/migrations/0002_stacklog.py +47 -0
- stackamole/migrations/0003_blanks.py +84 -0
- stackamole/migrations/0004_auto_20190715_1053.py +24 -0
- stackamole/migrations/0005_auto_20190811_1555.py +56 -0
- stackamole/migrations/0006_auto_20200107_1332.py +44 -0
- stackamole/migrations/0007_add_delete_by_and_delete_age.py +34 -0
- stackamole/migrations/0008_add_database_defaults_for_stack_key_and_password.py +23 -0
- stackamole/migrations/0009_add_null_true_for_key_and_password.py +23 -0
- stackamole/migrations/0010_add_user_foreign_key.py +52 -0
- stackamole/migrations/0011_allow_null_for_learner.py +29 -0
- stackamole/migrations/0012_add_suspend_by.py +21 -0
- stackamole/migrations/0013_migrate_app_label.py +71 -0
- stackamole/migrations/__init__.py +0 -0
- stackamole/models.py +106 -0
- stackamole/openstack.py +132 -0
- stackamole/provider.py +437 -0
- stackamole/public/css/main.css +203 -0
- stackamole/public/js/client.js +269 -0
- stackamole/public/js/guacamole-common-js/0.9.13-incubating-all.min.js +1 -0
- stackamole/public/js/guacamole-common-js/1.4.0-all.min.js +155 -0
- stackamole/public/js/guacamole-common-js/1.5.2-all.min.js +161 -0
- stackamole/public/js/guacamole-common-js/1.5.4-all.min.js +161 -0
- stackamole/public/js/guacamole-common-js/1.5.5-all.min.js +161 -0
- stackamole/public/js/main.js +492 -0
- stackamole/public/js/plugins.js +95 -0
- stackamole/stackamole.py +1226 -0
- stackamole/static/html/lab.html +88 -0
- stackamole/static/html/main.html +93 -0
- stackamole/tasks.py +947 -0
- stackamole_guacamole_client/__init__.py +0 -0
- stackamole_guacamole_client/asgi.py +26 -0
- stackamole_guacamole_client/consumers.py +95 -0
- stackamole_guacamole_client/settings.py +87 -0
- stackamole_xblock-9.4.0.dist-info/METADATA +647 -0
- stackamole_xblock-9.4.0.dist-info/RECORD +89 -0
- stackamole_xblock-9.4.0.dist-info/WHEEL +5 -0
- stackamole_xblock-9.4.0.dist-info/entry_points.txt +3 -0
- stackamole_xblock-9.4.0.dist-info/licenses/LICENSE +661 -0
- stackamole_xblock-9.4.0.dist-info/top_level.txt +7 -0
- tests/__init__.py +0 -0
- tests/resources/course/stackamole/fake_lab_1.xml +9 -0
- tests/resources/course/stackamole/fake_lab_2.xml +9 -0
- tests/resources/course/stackamole/fake_lab_hook_events_1.xml +4 -0
- tests/resources/course/stackamole/fake_lab_hook_events_2.xml +3 -0
- tests/resources/course/stackamole/fake_lab_no_port_name.xml +3 -0
- tests/resources/course/stackamole/fake_lab_no_port_number.xml +3 -0
- tests/resources/course/stackamole/fake_lab_no_provider_capacity.xml +5 -0
- tests/resources/course/stackamole/fake_lab_no_provider_name.xml +3 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_admin.py +170 -0
- tests/unit/test_commands.py +31 -0
- tests/unit/test_common.py +307 -0
- tests/unit/test_jobs.py +649 -0
- tests/unit/test_models.py +72 -0
- tests/unit/test_openstack.py +149 -0
- tests/unit/test_provider.py +847 -0
- tests/unit/test_stackamole.py +1664 -0
- tests/unit/test_tasks.py +2324 -0
stackamole/common.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import errno
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
import paramiko
|
|
6
|
+
import logging
|
|
7
|
+
import six
|
|
8
|
+
|
|
9
|
+
from io import StringIO
|
|
10
|
+
from socket import timeout as SocketTimeout
|
|
11
|
+
from paramiko import RSAKey, Ed25519Key
|
|
12
|
+
from paramiko.ssh_exception import (AuthenticationException,
|
|
13
|
+
SSHException,
|
|
14
|
+
NoValidConnectionsError)
|
|
15
|
+
from django.conf import settings as django_settings
|
|
16
|
+
from pymongo.errors import PyMongoError
|
|
17
|
+
from tenacity import (
|
|
18
|
+
retry,
|
|
19
|
+
retry_if_exception_type,
|
|
20
|
+
stop_after_attempt,
|
|
21
|
+
wait_exponential,
|
|
22
|
+
before_sleep_log,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .models import Stack
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
ACTIONS = (
|
|
31
|
+
ADOPT,
|
|
32
|
+
CHECK,
|
|
33
|
+
CREATE,
|
|
34
|
+
DELETE,
|
|
35
|
+
LAUNCH,
|
|
36
|
+
RESTORE,
|
|
37
|
+
RESUME,
|
|
38
|
+
ROLLBACK,
|
|
39
|
+
SNAPSHOT,
|
|
40
|
+
SUSPEND,
|
|
41
|
+
UPDATE
|
|
42
|
+
) = (
|
|
43
|
+
'ADOPT',
|
|
44
|
+
'CHECK',
|
|
45
|
+
'CREATE',
|
|
46
|
+
'DELETE',
|
|
47
|
+
'LAUNCH',
|
|
48
|
+
'RESTORE',
|
|
49
|
+
'RESUME',
|
|
50
|
+
'ROLLBACK',
|
|
51
|
+
'SNAPSHOT',
|
|
52
|
+
'SUSPEND',
|
|
53
|
+
'UPDATE'
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
STATUSES = (
|
|
57
|
+
COMPLETE,
|
|
58
|
+
ERROR,
|
|
59
|
+
FAILED,
|
|
60
|
+
IN_PROGRESS,
|
|
61
|
+
ISSUED,
|
|
62
|
+
PENDING,
|
|
63
|
+
RETRY,
|
|
64
|
+
TIMEOUT
|
|
65
|
+
) = (
|
|
66
|
+
'COMPLETE',
|
|
67
|
+
'ERROR',
|
|
68
|
+
'FAILED',
|
|
69
|
+
'IN_PROGRESS',
|
|
70
|
+
'ISSUED',
|
|
71
|
+
'PENDING',
|
|
72
|
+
'RETRY',
|
|
73
|
+
'TIMEOUT'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Dynamically define all possible states as attributes in this module. To
|
|
77
|
+
# prevent flake8 from complaining about undefined attributes, we add noqa: F821
|
|
78
|
+
# where necessary, below.
|
|
79
|
+
ALL_STATES = tuple(['%s_%s' % (a, s) for a in ACTIONS for s in STATUSES])
|
|
80
|
+
module = sys.modules[__name__]
|
|
81
|
+
for state in ALL_STATES:
|
|
82
|
+
setattr(module, state, state)
|
|
83
|
+
|
|
84
|
+
VALID_STATES = (
|
|
85
|
+
ADOPT_COMPLETE, # noqa: F821
|
|
86
|
+
ADOPT_FAILED, # noqa: F821
|
|
87
|
+
ADOPT_IN_PROGRESS, # noqa: F821
|
|
88
|
+
CHECK_COMPLETE, # noqa: F821
|
|
89
|
+
CHECK_FAILED, # noqa: F821
|
|
90
|
+
CHECK_IN_PROGRESS, # noqa: F821
|
|
91
|
+
CREATE_COMPLETE, # noqa: F821
|
|
92
|
+
CREATE_FAILED, # noqa: F821
|
|
93
|
+
CREATE_IN_PROGRESS, # noqa: F821
|
|
94
|
+
DELETE_COMPLETE, # noqa: F821
|
|
95
|
+
DELETE_FAILED, # noqa: F821
|
|
96
|
+
DELETE_IN_PROGRESS, # noqa: F821
|
|
97
|
+
DELETE_PENDING, # noqa: F821
|
|
98
|
+
LAUNCH_ERROR, # noqa: F821
|
|
99
|
+
LAUNCH_PENDING, # noqa: F821
|
|
100
|
+
LAUNCH_TIMEOUT, # noqa: F821
|
|
101
|
+
RESTORE_COMPLETE, # noqa: F821
|
|
102
|
+
RESTORE_FAILED, # noqa: F821
|
|
103
|
+
RESTORE_IN_PROGRESS, # noqa: F821
|
|
104
|
+
RESUME_COMPLETE, # noqa: F821
|
|
105
|
+
RESUME_FAILED, # noqa: F821
|
|
106
|
+
RESUME_IN_PROGRESS, # noqa: F821
|
|
107
|
+
ROLLBACK_COMPLETE, # noqa: F821
|
|
108
|
+
ROLLBACK_FAILED, # noqa: F821
|
|
109
|
+
ROLLBACK_IN_PROGRESS, # noqa: F821
|
|
110
|
+
SNAPSHOT_COMPLETE, # noqa: F821
|
|
111
|
+
SNAPSHOT_FAILED, # noqa: F821
|
|
112
|
+
SNAPSHOT_IN_PROGRESS, # noqa: F821
|
|
113
|
+
SUSPEND_COMPLETE, # noqa: F821
|
|
114
|
+
SUSPEND_FAILED, # noqa: F821
|
|
115
|
+
SUSPEND_IN_PROGRESS, # noqa: F821
|
|
116
|
+
SUSPEND_ISSUED, # noqa: F821
|
|
117
|
+
SUSPEND_PENDING, # noqa: F821
|
|
118
|
+
SUSPEND_RETRY, # noqa: F821
|
|
119
|
+
UPDATE_COMPLETE, # noqa: F821
|
|
120
|
+
UPDATE_FAILED, # noqa: F821
|
|
121
|
+
UPDATE_IN_PROGRESS # noqa: F821
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
UP_STATES = (
|
|
125
|
+
CREATE_COMPLETE, # noqa: F821
|
|
126
|
+
RESUME_COMPLETE, # noqa: F821
|
|
127
|
+
UPDATE_COMPLETE, # noqa: F821
|
|
128
|
+
ROLLBACK_COMPLETE, # noqa: F821
|
|
129
|
+
SNAPSHOT_COMPLETE # noqa: F821
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
DOWN_STATES = (
|
|
133
|
+
SUSPEND_COMPLETE, # noqa: F821
|
|
134
|
+
DELETE_COMPLETE # noqa: F821
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
PENDING_STATES = (
|
|
138
|
+
LAUNCH_PENDING, # noqa: F821
|
|
139
|
+
SUSPEND_PENDING, # noqa: F821
|
|
140
|
+
DELETE_PENDING # noqa: F821
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
OCCUPANCY_STATES = (
|
|
144
|
+
CREATE_COMPLETE, # noqa: F821
|
|
145
|
+
RESUME_COMPLETE, # noqa: F821
|
|
146
|
+
UPDATE_COMPLETE, # noqa: F821
|
|
147
|
+
ROLLBACK_COMPLETE, # noqa: F821
|
|
148
|
+
SNAPSHOT_COMPLETE, # noqa: F821
|
|
149
|
+
LAUNCH_PENDING, # noqa: F821
|
|
150
|
+
SUSPEND_PENDING, # noqa: F821
|
|
151
|
+
SUSPEND_ISSUED, # noqa: F821
|
|
152
|
+
SUSPEND_RETRY, # noqa: F821
|
|
153
|
+
SUSPEND_COMPLETE, # noqa: F821
|
|
154
|
+
DELETE_PENDING # noqa: F821
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
SETTINGS_KEY = 'stackamole'
|
|
158
|
+
|
|
159
|
+
DEFAULT_SETTINGS = {
|
|
160
|
+
"terminal_url": "/stackamole-xblock/",
|
|
161
|
+
"terminal_color_scheme": "white-black",
|
|
162
|
+
"terminal_font_name": "monospace",
|
|
163
|
+
"terminal_font_size": "10",
|
|
164
|
+
"instructions_layout": "above",
|
|
165
|
+
"enable_fullscreen": False,
|
|
166
|
+
"launch_timeout": 900,
|
|
167
|
+
"remote_exec_timeout": 300,
|
|
168
|
+
"suspend_timeout": 120,
|
|
169
|
+
"suspend_interval": 60,
|
|
170
|
+
"suspend_concurrency": 4,
|
|
171
|
+
"suspend_task_timeout": 900,
|
|
172
|
+
"check_timeout": 120,
|
|
173
|
+
"delete_age": 14,
|
|
174
|
+
"delete_attempts": 3,
|
|
175
|
+
"delete_interval": 86400,
|
|
176
|
+
"delete_task_timeout": 900,
|
|
177
|
+
"sleep_timeout": 10,
|
|
178
|
+
"ssh_connect_timeout": 10,
|
|
179
|
+
"js_timeouts": {
|
|
180
|
+
"status": 15000,
|
|
181
|
+
"keepalive": 30000,
|
|
182
|
+
"idle": 3600000,
|
|
183
|
+
"check": 5000
|
|
184
|
+
},
|
|
185
|
+
"providers": {},
|
|
186
|
+
"guacamole_js_version": '1.5.5',
|
|
187
|
+
"lab_usage_limit": None,
|
|
188
|
+
"lab_usage_limit_breach_policy": None,
|
|
189
|
+
"lab_resize_method": "reconnect"
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
SUPPORTED_LANGUAGES = [
|
|
193
|
+
'en', # English
|
|
194
|
+
'de-de', # Deutsch (Deutschland), German (Germany)
|
|
195
|
+
'el', # Ελληνικά, Greek
|
|
196
|
+
'es-419', # Español (Latinoamérica), Spanish (Latin America)
|
|
197
|
+
'et-ee', # Eesti (Eesti), Estonian (Estonia)
|
|
198
|
+
'hi', # हिन्दी, Hindi
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _(string):
|
|
203
|
+
"""
|
|
204
|
+
Dummy `gettext` replacement to make string extraction tools
|
|
205
|
+
scrape strings marked for translation.
|
|
206
|
+
Translations will actually be loaded via the translation service
|
|
207
|
+
in the XBlock runtime, but we also need to extract strings from
|
|
208
|
+
files that cannot access the service.
|
|
209
|
+
For example, some user-facing error or warning messages.
|
|
210
|
+
"""
|
|
211
|
+
return string
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class RemoteExecException(Exception):
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class RemoteExecTimeout(RemoteExecException):
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if sys.version_info < (3,):
|
|
223
|
+
def b(x):
|
|
224
|
+
return x
|
|
225
|
+
else:
|
|
226
|
+
import codecs
|
|
227
|
+
|
|
228
|
+
def b(x):
|
|
229
|
+
return codecs.latin_1_encode(x)[0]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_xblock_settings():
|
|
233
|
+
try:
|
|
234
|
+
xblock_settings = django_settings.XBLOCK_SETTINGS
|
|
235
|
+
except AttributeError:
|
|
236
|
+
settings = DEFAULT_SETTINGS
|
|
237
|
+
else:
|
|
238
|
+
settings = xblock_settings.get(
|
|
239
|
+
SETTINGS_KEY, DEFAULT_SETTINGS)
|
|
240
|
+
|
|
241
|
+
return settings
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def update_stack(name, course_id, student_id, data):
|
|
245
|
+
stack = Stack.objects.select_for_update().get(
|
|
246
|
+
student_id=student_id,
|
|
247
|
+
course_id=course_id,
|
|
248
|
+
name=name
|
|
249
|
+
)
|
|
250
|
+
update_stack_fields(stack, data)
|
|
251
|
+
stack.save(update_fields=list(data.keys()))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def update_stack_fields(stack, data):
|
|
255
|
+
for field, value in data.items():
|
|
256
|
+
if hasattr(stack, field):
|
|
257
|
+
setattr(stack, field, value)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_stack(name, course_id, student_id, prop=None):
|
|
261
|
+
stack = Stack.objects.get(
|
|
262
|
+
student_id=student_id,
|
|
263
|
+
course_id=course_id,
|
|
264
|
+
name=name
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if prop:
|
|
268
|
+
return getattr(stack, prop)
|
|
269
|
+
else:
|
|
270
|
+
return stack
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# If PyMongoError is raised here, try again (max attempts = 3)
|
|
274
|
+
# Before every subsequent retry, wait
|
|
275
|
+
# Use before_sleep_log to log attempts
|
|
276
|
+
# Reraise the exception from last attempt
|
|
277
|
+
@retry(retry=retry_if_exception_type(PyMongoError),
|
|
278
|
+
stop=stop_after_attempt(3),
|
|
279
|
+
wait=wait_exponential(),
|
|
280
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
281
|
+
reraise=True)
|
|
282
|
+
def read_from_contentstore(course_key, path):
|
|
283
|
+
"""
|
|
284
|
+
Loads a file directly from the course's content store.
|
|
285
|
+
|
|
286
|
+
"""
|
|
287
|
+
contents = None
|
|
288
|
+
try:
|
|
289
|
+
from xmodule.contentstore.content import StaticContent
|
|
290
|
+
from xmodule.contentstore.django import contentstore
|
|
291
|
+
from opaque_keys.edx.locator import CourseLocator
|
|
292
|
+
except ImportError:
|
|
293
|
+
# We're not running under edx-platform, so ignore.
|
|
294
|
+
pass
|
|
295
|
+
else:
|
|
296
|
+
if isinstance(course_key, six.text_type):
|
|
297
|
+
course_key = CourseLocator.from_string(course_key)
|
|
298
|
+
loc = StaticContent.compute_location(course_key, path)
|
|
299
|
+
asset = contentstore().find(loc)
|
|
300
|
+
contents = asset.data
|
|
301
|
+
|
|
302
|
+
return contents
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def ssh_to(user, ip, key):
|
|
306
|
+
ssh = paramiko.SSHClient()
|
|
307
|
+
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
308
|
+
|
|
309
|
+
pkey = None
|
|
310
|
+
for key_type in (RSAKey, Ed25519Key):
|
|
311
|
+
try:
|
|
312
|
+
pkey = key_type.from_private_key(StringIO(key))
|
|
313
|
+
except SSHException:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
settings = get_xblock_settings()
|
|
317
|
+
sleep_timeout = settings.get("sleep_timeout", 10)
|
|
318
|
+
ssh_connect_timeout = settings.get("ssh_connect_timeout", 10)
|
|
319
|
+
|
|
320
|
+
connected = False
|
|
321
|
+
while not connected:
|
|
322
|
+
try:
|
|
323
|
+
ssh.connect(ip,
|
|
324
|
+
username=user,
|
|
325
|
+
pkey=pkey,
|
|
326
|
+
timeout=ssh_connect_timeout)
|
|
327
|
+
except (EOFError,
|
|
328
|
+
AuthenticationException,
|
|
329
|
+
SSHException,
|
|
330
|
+
NoValidConnectionsError,
|
|
331
|
+
SocketTimeout) as e:
|
|
332
|
+
# Be more persistent than Paramiko normally
|
|
333
|
+
# is, and keep retrying.
|
|
334
|
+
logger.info("Got %s during SSH connection to ip (%s), retrying." %
|
|
335
|
+
(e.__class__.__name__, ip))
|
|
336
|
+
except EnvironmentError as enve:
|
|
337
|
+
if enve.errno in (errno.EAGAIN,
|
|
338
|
+
errno.ENETDOWN,
|
|
339
|
+
errno.ENETUNREACH,
|
|
340
|
+
errno.ENETRESET,
|
|
341
|
+
errno.ECONNABORTED,
|
|
342
|
+
errno.ECONNRESET,
|
|
343
|
+
errno.ENOTCONN,
|
|
344
|
+
errno.EHOSTDOWN):
|
|
345
|
+
# Be more persistent than Paramiko normally
|
|
346
|
+
# is, and keep retrying.
|
|
347
|
+
logger.debug("Got errno %s during SSH connection "
|
|
348
|
+
"to ip (%s), retrying." % (enve.errno, ip))
|
|
349
|
+
elif enve.errno in (errno.ECONNREFUSED,
|
|
350
|
+
errno.EHOSTUNREACH):
|
|
351
|
+
# Paramiko should catch and wrap
|
|
352
|
+
# these. They should never bubble
|
|
353
|
+
# up. Still, continue being more
|
|
354
|
+
# persistent, and retry.
|
|
355
|
+
logger.warning("Got errno %s during SSH connection "
|
|
356
|
+
"to ip (%s). Paramiko bug? Retrying." %
|
|
357
|
+
(enve.errno, ip))
|
|
358
|
+
else:
|
|
359
|
+
# Anything else is an unexpected error.
|
|
360
|
+
raise
|
|
361
|
+
else:
|
|
362
|
+
connected = True
|
|
363
|
+
|
|
364
|
+
if not connected:
|
|
365
|
+
time.sleep(sleep_timeout)
|
|
366
|
+
|
|
367
|
+
return ssh
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def remote_exec(ssh, script, params=None, reuse_sftp=None):
|
|
371
|
+
if reuse_sftp:
|
|
372
|
+
sftp = reuse_sftp
|
|
373
|
+
else:
|
|
374
|
+
sftp = ssh.open_sftp()
|
|
375
|
+
|
|
376
|
+
# Generate a temporary filename
|
|
377
|
+
script_file = '/tmp/.%s' % uuid.uuid4()
|
|
378
|
+
|
|
379
|
+
# Open the file remotely and write the script out to it.
|
|
380
|
+
f = sftp.open(script_file, 'w')
|
|
381
|
+
f.write(script)
|
|
382
|
+
f.close()
|
|
383
|
+
|
|
384
|
+
# Make it executable.
|
|
385
|
+
sftp.chmod(script_file, 0o775)
|
|
386
|
+
|
|
387
|
+
# Add command line arguments, if any.
|
|
388
|
+
if params:
|
|
389
|
+
command = "%s %s" % (script_file, params)
|
|
390
|
+
else:
|
|
391
|
+
command = script_file
|
|
392
|
+
|
|
393
|
+
# Run it.
|
|
394
|
+
_, stdout, stderr = ssh.exec_command(command)
|
|
395
|
+
|
|
396
|
+
# Wait for it to complete.
|
|
397
|
+
settings = get_xblock_settings()
|
|
398
|
+
timeout = settings.get("remote_exec_timeout", 300)
|
|
399
|
+
try:
|
|
400
|
+
start = time.time()
|
|
401
|
+
while not stdout.channel.exit_status_ready():
|
|
402
|
+
if timeout and time.time() >= start + timeout:
|
|
403
|
+
error_msg = ("Remote execution timeout after [%d] seconds." %
|
|
404
|
+
timeout)
|
|
405
|
+
raise RemoteExecTimeout(error_msg)
|
|
406
|
+
|
|
407
|
+
time.sleep(1)
|
|
408
|
+
finally:
|
|
409
|
+
# Remove the file
|
|
410
|
+
sftp.remove(script_file)
|
|
411
|
+
|
|
412
|
+
# Check for errors
|
|
413
|
+
retval = stdout.channel.recv_exit_status()
|
|
414
|
+
error_msg = None
|
|
415
|
+
if retval != 0:
|
|
416
|
+
error_msg = stderr.read()
|
|
417
|
+
raise RemoteExecException(error_msg)
|
|
418
|
+
|
|
419
|
+
# Close the sftp session
|
|
420
|
+
if not reuse_sftp:
|
|
421
|
+
sftp.close()
|
|
422
|
+
|
|
423
|
+
return retval
|
stackamole/jobs.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import print_function
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from django.db import transaction, close_old_connections
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
from .models import Stack
|
|
9
|
+
from .provider import Provider
|
|
10
|
+
from .tasks import DeleteStackTask, SuspendStackTask
|
|
11
|
+
from .common import (
|
|
12
|
+
UP_STATES,
|
|
13
|
+
SUSPEND_PENDING,
|
|
14
|
+
SUSPEND_RETRY,
|
|
15
|
+
SUSPEND_FAILED,
|
|
16
|
+
DELETE_COMPLETE,
|
|
17
|
+
DELETE_IN_PROGRESS,
|
|
18
|
+
DELETE_PENDING,
|
|
19
|
+
LAUNCH_PENDING,
|
|
20
|
+
CREATE_COMPLETE
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AbstractJob(object):
|
|
25
|
+
"""
|
|
26
|
+
Parent job class.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
settings = {}
|
|
30
|
+
|
|
31
|
+
def __init__(self, settings):
|
|
32
|
+
self.settings = settings
|
|
33
|
+
|
|
34
|
+
def log(self, msg):
|
|
35
|
+
"""
|
|
36
|
+
Log message to stdout.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
print(msg, file=sys.stderr)
|
|
40
|
+
|
|
41
|
+
def refresh_db(self):
|
|
42
|
+
"""
|
|
43
|
+
Destroy old or unusable database connections. Django will reconnect as
|
|
44
|
+
needed.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
close_old_connections()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SuspenderJob(AbstractJob):
|
|
51
|
+
"""
|
|
52
|
+
Suspends stacks.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
def run(self):
|
|
56
|
+
timeout = self.settings.get("suspend_timeout", 120)
|
|
57
|
+
concurrency = self.settings.get("suspend_concurrency", 4)
|
|
58
|
+
timedelta = timezone.timedelta(seconds=timeout)
|
|
59
|
+
cutoff = timezone.now() - timedelta
|
|
60
|
+
|
|
61
|
+
# SUSPEND_RETRY is no longer needed: we always retry on a suspend
|
|
62
|
+
# failure. It is, thus, deprecated.
|
|
63
|
+
states = list(UP_STATES) + [SUSPEND_RETRY, SUSPEND_FAILED]
|
|
64
|
+
|
|
65
|
+
self.refresh_db()
|
|
66
|
+
|
|
67
|
+
# Get stacks to suspend
|
|
68
|
+
with transaction.atomic():
|
|
69
|
+
stacks_without_suspend_by = Stack.objects.select_for_update().filter( # noqa: E501
|
|
70
|
+
suspend_timestamp__isnull=False
|
|
71
|
+
).filter(
|
|
72
|
+
suspend_by__isnull=True
|
|
73
|
+
).filter(
|
|
74
|
+
suspend_timestamp__lt=cutoff
|
|
75
|
+
).filter(
|
|
76
|
+
status__in=states
|
|
77
|
+
).exclude(
|
|
78
|
+
provider__exact=''
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
stacks_with_suspend_by = Stack.objects.select_for_update().filter(
|
|
82
|
+
suspend_timestamp__isnull=False
|
|
83
|
+
).filter(
|
|
84
|
+
suspend_by__isnull=False
|
|
85
|
+
).filter(
|
|
86
|
+
suspend_by__lt=timezone.now()
|
|
87
|
+
).filter(
|
|
88
|
+
status__in=states
|
|
89
|
+
).exclude(
|
|
90
|
+
provider__exact=''
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
stacks = stacks_without_suspend_by.union(
|
|
94
|
+
stacks_with_suspend_by).order_by(
|
|
95
|
+
'suspend_timestamp')[:concurrency]
|
|
96
|
+
|
|
97
|
+
for stack in stacks:
|
|
98
|
+
stack.status = SUSPEND_PENDING
|
|
99
|
+
stack.save(update_fields=["status"])
|
|
100
|
+
|
|
101
|
+
# Suspend them
|
|
102
|
+
for stack in stacks:
|
|
103
|
+
self.suspend_stack(stack)
|
|
104
|
+
|
|
105
|
+
def suspend_stack(self, stack):
|
|
106
|
+
"""
|
|
107
|
+
Start an asynchronous suspend task with no expiration and ignoring
|
|
108
|
+
results.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
soft_time_limit = self.settings.get("suspend_task_timeout", 900)
|
|
112
|
+
hard_time_limit = soft_time_limit + 30
|
|
113
|
+
kwargs = {
|
|
114
|
+
"stack_id": stack.id
|
|
115
|
+
}
|
|
116
|
+
SuspendStackTask.apply_async(
|
|
117
|
+
kwargs=kwargs,
|
|
118
|
+
soft_time_limit=soft_time_limit,
|
|
119
|
+
time_limit=hard_time_limit,
|
|
120
|
+
ignore_result=True
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ReaperJob(AbstractJob):
|
|
125
|
+
"""
|
|
126
|
+
Deletes old stacks.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
def run(self):
|
|
130
|
+
dont_delete = [DELETE_PENDING,
|
|
131
|
+
DELETE_COMPLETE,
|
|
132
|
+
DELETE_IN_PROGRESS,
|
|
133
|
+
LAUNCH_PENDING,
|
|
134
|
+
CREATE_COMPLETE]
|
|
135
|
+
|
|
136
|
+
self.refresh_db()
|
|
137
|
+
|
|
138
|
+
# Get stacks to delete
|
|
139
|
+
with transaction.atomic():
|
|
140
|
+
stacks = Stack.objects.select_for_update().filter(
|
|
141
|
+
suspend_timestamp__isnull=False
|
|
142
|
+
).filter(
|
|
143
|
+
delete_by__isnull=False
|
|
144
|
+
).filter(
|
|
145
|
+
delete_by__lt=timezone.now()
|
|
146
|
+
).exclude(
|
|
147
|
+
status__in=dont_delete
|
|
148
|
+
).exclude(
|
|
149
|
+
provider__exact=''
|
|
150
|
+
).order_by('suspend_timestamp')
|
|
151
|
+
|
|
152
|
+
for stack in stacks:
|
|
153
|
+
stack.status = DELETE_PENDING
|
|
154
|
+
stack.save(update_fields=["status"])
|
|
155
|
+
|
|
156
|
+
# Delete them
|
|
157
|
+
for stack in stacks:
|
|
158
|
+
self.delete_stack(stack)
|
|
159
|
+
|
|
160
|
+
# Apocalypse pass: kill all zombie stacks
|
|
161
|
+
providers = self.settings.get("providers", {})
|
|
162
|
+
for provider_name in providers:
|
|
163
|
+
provider = Provider.init(provider_name)
|
|
164
|
+
try:
|
|
165
|
+
provider_stacks = provider.get_stacks()
|
|
166
|
+
except Exception as e:
|
|
167
|
+
error_msg = "Error listing stacks for provider [%s]: %s" % (
|
|
168
|
+
provider_name, str(e))
|
|
169
|
+
self.log(error_msg)
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
for provider_stack in provider_stacks:
|
|
173
|
+
stack_name = provider_stack["name"]
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
stack = Stack.objects.get(name=stack_name)
|
|
177
|
+
except Stack.DoesNotExist:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if stack.status == DELETE_COMPLETE:
|
|
181
|
+
error_msg = ("Zombie stack [%s] detected at provider [%s]"
|
|
182
|
+
% (stack_name, provider_name))
|
|
183
|
+
self.log(error_msg)
|
|
184
|
+
stack.provider = provider_name
|
|
185
|
+
stack.status = DELETE_PENDING
|
|
186
|
+
stack.error_msg = error_msg
|
|
187
|
+
stack.save(update_fields=[
|
|
188
|
+
"provider",
|
|
189
|
+
"status",
|
|
190
|
+
"error_msg"
|
|
191
|
+
])
|
|
192
|
+
|
|
193
|
+
self.delete_stack(stack)
|
|
194
|
+
|
|
195
|
+
def delete_stack(self, stack):
|
|
196
|
+
"""
|
|
197
|
+
Start an asynchronous delete task with no expiration and ignoring
|
|
198
|
+
results.
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
soft_time_limit = self.settings.get("delete_task_timeout", 900)
|
|
202
|
+
hard_time_limit = soft_time_limit + 30
|
|
203
|
+
kwargs = {
|
|
204
|
+
"stack_id": stack.id
|
|
205
|
+
}
|
|
206
|
+
DeleteStackTask.apply_async(
|
|
207
|
+
kwargs=kwargs,
|
|
208
|
+
soft_time_limit=soft_time_limit,
|
|
209
|
+
time_limit=hard_time_limit,
|
|
210
|
+
ignore_result=True
|
|
211
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Configuration for i18n workflow.
|
|
2
|
+
|
|
3
|
+
locales:
|
|
4
|
+
- en # English - Source Language
|
|
5
|
+
- de_DE # German
|
|
6
|
+
- el # Greek
|
|
7
|
+
- es_419 # Spanish (Latin America)
|
|
8
|
+
- et_EE # Estonian (Estonia)
|
|
9
|
+
- hi # Hindi
|
|
10
|
+
|
|
11
|
+
# Directories we don't search for strings.
|
|
12
|
+
ignore_dirs:
|
|
13
|
+
- 'management'
|
|
14
|
+
- 'migrations'
|