-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add codejail_service app for transition to containerized codejail
- Loading branch information
Showing
17 changed files
with
878 additions
and
65 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
Codejail Service | ||
################ | ||
|
||
When installed in the LMS as a plugin app, the ``codejail_service`` app allows the CMS to delegate codejail executions to the LMS across the network. | ||
|
||
This is intended as a `temporary situation <https://github.com/openedx/edx-platform/issues/33538>`_ with the following goals: | ||
|
||
- Unblock containerization of the CMS. Codejail cannot be readily containerized due to its reliance on AppArmor, but if codejail execution is outsourced, then we can containerize CMS first and will be in a better position to containerize the LMS afterwards. | ||
- Exercise the remote-codejail pathway and have an opportunity to discover and implement needed improvements before fully building out a separate, dedicated codejail service. | ||
|
||
Setup | ||
***** | ||
|
||
In LMS: | ||
|
||
- Install ``edx-arch-experiments`` as a dependency | ||
- Identify a service account that will be permitted to make calls to the codejail service and ensure it has the ``is_staff`` Django flag. In devstack, this would be ``cms_worker``. | ||
- In Djano admin, under ``Django OAuth Toolkit > Applications``, find or create a Client Credentials application for that service user. | ||
|
||
In CMS: | ||
|
||
- Set ``ENABLE_CODEJAIL_REST_SERVICE`` to ``True`` | ||
- Set ``CODE_JAIL_REST_SERVICE_HOST`` to the URL origin of the LMS (e.g. ``http://edx.devstack.lms:18000`` in devstack) | ||
- Keep ``CODE_JAIL_REST_SERVICE_REMOTE_EXEC`` at its default of ``xmodule.capa.safe_exec.remote_exec.send_safe_exec_request_v0`` | ||
- Adjust ``CODE_JAIL_REST_SERVICE_CONNECT_TIMEOUT`` and ``CODE_JAIL_REST_SERVICE_READ_TIMEOUT`` if needed | ||
- Set ``CODE_JAIL_REST_SERVICE_OAUTH_URL`` to the LMS OAuth endpoint (e.g. ``http://edx.devstack.lms:18000`` in devstack) | ||
- Set ``CODE_JAIL_REST_SERVICE_CLIENT_ID`` and ``CODE_JAIL_REST_SERVICE_CLIENT_SECRET`` to the client credentials app ID and secret that you identified in the LMS. (In devstack, these would be ``cms-backend-service-key`` and ``cms-backend-service-secret``.) |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
""" | ||
App for running answer submissions inside of codejail. | ||
""" | ||
|
||
from django.apps import AppConfig | ||
from edx_django_utils.plugins.constants import PluginURLs | ||
|
||
|
||
class CodejailService(AppConfig): | ||
""" | ||
Django application to run things in codejail. | ||
""" | ||
name = 'edx_arch_experiments.codejail_service' | ||
|
||
plugin_app = { | ||
PluginURLs.CONFIG: { | ||
'lms.djangoapp': { | ||
PluginURLs.NAMESPACE: 'codejail_service', | ||
PluginURLs.RELATIVE_PATH: 'urls', # .urls is the default place for plugin views | ||
} | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
""" | ||
Codejail service URLs. | ||
""" | ||
|
||
from django.urls import path | ||
|
||
from . import views | ||
|
||
urlpatterns = [ | ||
path('api/v0/code-exec', views.code_exec_view_v0, name="code_exec_v0"), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
""" | ||
Codejail service API. | ||
""" | ||
|
||
import json | ||
import logging | ||
from copy import deepcopy | ||
|
||
import jsonschema | ||
from codejail.safe_exec import SafeExecException, safe_exec | ||
from django.conf import settings | ||
from edx_toggles.toggles import SettingToggle | ||
from rest_framework.decorators import api_view, parser_classes, permission_classes | ||
from rest_framework.parsers import FormParser, MultiPartParser | ||
from rest_framework.permissions import IsAdminUser | ||
from rest_framework.response import Response | ||
|
||
log = logging.getLogger(__name__) | ||
|
||
# .. toggle_name: CODEJAIL_SERVICE_ENABLED | ||
# .. toggle_implementation: SettingToggle | ||
# .. toggle_default: True | ||
# .. toggle_description: If True, codejail execution calls will be accepted over the network, | ||
# allowing this IDA to act as a codejail service for another IDA. | ||
# .. toggle_use_cases: circuit_breaker | ||
# .. toggle_creation_date: 2023-12-21 | ||
# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/33538 | ||
CODEJAIL_SERVICE_ENABLED = SettingToggle('CODEJAIL_SERVICE_ENABLED', default=True, module_name=__name__) | ||
|
||
# Schema for the JSON passed in the v0 API's 'payload' field. | ||
payload_schema = { | ||
'type': 'object', | ||
'properties': { | ||
'code': {'type': 'string'}, | ||
'globals_dict': {'type': 'object'}, | ||
# Some of these are configured as union types because | ||
# edx-platform appears to currently default to None for some | ||
# of them (rather than omitting the keys.) | ||
'python_path': { | ||
'anyOf': [ | ||
{ | ||
'type': 'array', | ||
'items': {'type': 'string'}, | ||
}, | ||
{'type': 'null'}, | ||
], | ||
}, | ||
'limit_overrides_context': { | ||
'anyOf': [ | ||
{'type': 'string'}, | ||
{'type': 'null'}, | ||
], | ||
}, | ||
'slug': { | ||
'anyOf': [ | ||
{'type': 'string'}, | ||
{'type': 'null'}, | ||
], | ||
}, | ||
'unsafely': {'type': 'boolean'}, | ||
}, | ||
'required': ['code'], | ||
} | ||
|
||
# A note on the authorization model used here: | ||
# | ||
# We really just need one service account to be able to call this, and | ||
# then also allow is_staff to call it for convenience and debugging | ||
# purposes. | ||
# | ||
# To do this "right", I'd probably have to create an empty abstract | ||
# model, create a permission on it, create a group, add the permission | ||
# to the group, and add the service account to the group. Then I could | ||
# check the calling user's has_perm. If I wanted to use bridgekeeper | ||
# (as we're trying to do more of) I might be able to give bridgekeeper | ||
# a `@blanket_rule` that checks membership in the group, then use | ||
# bridgekeeper here instead of checking permissions directly, but it's | ||
# possible this wouldn't work because bridgekeeper might require there | ||
# to be a model instance to pass in (and there wouldn't be one, since | ||
# it's just an abstract model.) | ||
# | ||
# But... given that the service account will be is_staff, and I'll be | ||
# opening this up to is_staff alongside the intended service account, | ||
# and this is already a hacky intermediate solution... we can just use | ||
# the `IsAdminUser` permission class and be done with it. | ||
|
||
|
||
@api_view(['POST']) | ||
@parser_classes([FormParser, MultiPartParser]) | ||
@permission_classes([IsAdminUser]) | ||
def code_exec_view_v0(request): | ||
""" | ||
Executes code in a codejail sandbox for a remote caller. | ||
This implements the API used by edxapp's xmodule.capa.safe_exec.remote_exec. | ||
It accepts a POST of a form containing a `payload` value and zero or more | ||
extra files. | ||
The payload is JSON and contains the parameters to be sent to codejail's | ||
safe_exec (aside from `extra_files`). See payload_schema for type information. | ||
This API does not permit `unsafely=true`. | ||
""" | ||
if not CODEJAIL_SERVICE_ENABLED.is_enabled(): | ||
return Response("Codejail service not enabled", status=500) | ||
|
||
params_json = request.data['payload'] | ||
params = json.loads(params_json) | ||
jsonschema.validate(params, payload_schema) | ||
|
||
complete_code = params['code'] # includes standard prolog | ||
input_globals_dict = params.get('globals_dict') or {} | ||
python_path = params.get('python_path') or [] | ||
limit_overrides_context = params.get('limit_overrides_context') | ||
slug = params.get('slug') | ||
unsafely = params.get('unsafely') | ||
|
||
extra_files = request.FILES | ||
|
||
# There's a risk of getting into a loop if e.g. the CMS asks the | ||
# LMS to run codejail executions on its behalf, and the LMS is | ||
# *also* inadvertently configured to call the LMS (itself). | ||
# There's no good reason to have a chain of >2 services passing | ||
# codejail requests along, so only allow execution here if we | ||
# aren't going to pass it along to someone else. | ||
if getattr(settings, 'ENABLE_CODEJAIL_REST_SERVICE', False): | ||
raise Exception( | ||
"Refusing to run codejail request from over the network " | ||
"when we're going to pass it to another IDA anyway" | ||
) | ||
|
||
# Far too dangerous to allow unsafe executions to come in over the | ||
# network, no matter who we think the caller is. The caller is the | ||
# one who has the context on safety. | ||
if unsafely: | ||
raise Exception("Refusing to run codejail request from over the network with unsafely=true") | ||
|
||
output_globals_dict = deepcopy(input_globals_dict) # Output dict will be mutated by safe_exec | ||
try: | ||
safe_exec( | ||
complete_code, | ||
output_globals_dict, | ||
python_path=python_path, | ||
extra_files=extra_files, | ||
limit_overrides_context=limit_overrides_context, | ||
slug=slug, | ||
) | ||
except SafeExecException as e: | ||
log.debug("CodejailService execution failed with: {e!r}") | ||
return Response({'emsg': f"Code jail execution failed: {e!r}"}) | ||
|
||
log.debug("CodejailService execution succeeded, with globals={output_globals_dict!r}") | ||
return Response({'globals_dict': output_globals_dict}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.