Skip to content

Commit

Permalink
Add environment option for workspaces and external graders (#4852)
Browse files Browse the repository at this point in the history
* Add `environment` option for workspaces

* Add `environment` option for external graders

* Fix optional flag for typechecker

* Add `external_grading_environment` to pg schema

* Change `environment` from array to object

* Fix pg schema

* Fix sync fallback

* Apply suggestions from code review

Co-authored-by: Nathan Walters <nathan@prairielearn.com>

* Add `environment` to grader host

* Apply suggestions from code review

Co-authored-by: Nathan Walters <nathan@prairielearn.com>

Co-authored-by: Nathan Walters <nathan@prairielearn.com>
  • Loading branch information
tdy and nwalters512 committed Oct 6, 2021
1 parent 9e940a6 commit 2727ae4
Show file tree
Hide file tree
Showing 15 changed files with 46 additions and 6 deletions.
2 changes: 2 additions & 0 deletions database/tables/questions.pg
Expand Up @@ -7,6 +7,7 @@ columns
external_grading_enable_networking: boolean
external_grading_enabled: boolean default false
external_grading_entrypoint: text
external_grading_environment: jsonb not null default '{}'::jsonb
external_grading_files: text[] default ARRAY[]::text[]
external_grading_image: text
external_grading_timeout: integer
Expand All @@ -27,6 +28,7 @@ columns
uuid: uuid
workspace_args: text
workspace_enable_networking: boolean
workspace_environment: jsonb not null default '{}'::jsonb
workspace_graded_files: text[] default ARRAY[]::text[]
workspace_home: text
workspace_image: text
Expand Down
2 changes: 2 additions & 0 deletions docs/externalGrading.md
Expand Up @@ -70,6 +70,8 @@ External grading configuration is done on a per-question basis. All configuratio

* `enableNetworking`: Allows the container to access the public internet. This is disabled by default to make secure, isolated execution the default behavior.

* `environment`: Environment variables to set inside the grading container. Set variables using `{"VAR": "value", ...}`, and unset variables using `{"VAR": null}` (no quotes around `null`). This property is optional.

Here's an example of a complete `externalGradingOptions` portion of a question's `info.json`:

```json
Expand Down
1 change: 1 addition & 0 deletions docs/workspaces/index.md
Expand Up @@ -65,6 +65,7 @@ The question's `info.json` should set the `singleVariant` and `workspaceOptions`
* `syncIgnore` (optional, default none): list of files or directories that will be excluded from sync
* `rewriteUrl` (optional, default true): if true, the URL will be rewritten such that the workspace container will see all requests as originating from /
* `enableNetworking` (optional, default false): whether the workspace should be allowed to connect to the public internet. This is disabled by default to make secure, isolated execution the default behavior. This restriction is not enforced when running PrairieLearn in local development mode. It is strongly recommended to use the default (no networking) for exam questions, because network access can be used to enable cheating. Only enable networking for homework questions, and only if it is strictly required, for example for downloading data from the internet.
* `environment` (optional, default `{}`): environment variables to set inside the workspace container. Set variables using `{"VAR": "value", ...}`, and unset variables using `{"VAR": null}` (no quotes around `null`).

#### `info.json` for ungraded workspace

Expand Down
4 changes: 4 additions & 0 deletions grader_host/index.js
Expand Up @@ -361,6 +361,7 @@ function runJob(info, callback) {
entrypoint,
timeout,
enableNetworking,
environment,
},
},
initFiles: {
Expand All @@ -372,6 +373,7 @@ function runJob(info, callback) {
let jobTimeout = timeout || 30;
let globalJobTimeout = jobTimeout * 2;
let jobEnableNetworking = enableNetworking || false;
let jobEnvironment = environment || {};

let jobFailed = false;
let globalJobTimeoutCleared = false;
Expand All @@ -394,6 +396,8 @@ function runJob(info, callback) {
(callback) => {
docker.createContainer({
Image: runImage,
// Convert {key: 'value'} to ['key=value'] and {key: null} to ['key'] for Docker API
Env: Object.entries(jobEnvironment).map(([k, v]) => v === null ? k : `${k}=${v}`),
AttachStdout: true,
AttachStderr: true,
Tty: true,
Expand Down
2 changes: 2 additions & 0 deletions lib/externalGraderLocal.js
Expand Up @@ -89,6 +89,8 @@ class Grader {
(callback) => {
docker.createContainer({
Image: question.external_grading_image,
// Convert {key: 'value'} to ['key=value'] and {key: null} to ['key'] for Docker API
Env: Object.entries(question.external_grading_environment).map(([k, v]) => v === null ? k : `${k}=${v}`),
AttachStdout: true,
AttachStderr: true,
Tty: true,
Expand Down
1 change: 1 addition & 0 deletions lib/externalGraderSqs.js
Expand Up @@ -118,6 +118,7 @@ function sendJobToQueue(jobId, question, config, callback) {
csrfToken: csrf.generateToken({url: '/pl/webhooks/grading'}, config.secretKey),
timeout: question.external_grading_timeout || config.externalGradingDefaultTimeout,
enableNetworking: question.external_grading_enable_networking || false,
environment: question.external_grading_environment || {},
};
const params = {
QueueUrl: QUEUE_URL,
Expand Down
1 change: 1 addition & 0 deletions migrations/243_questions__workspace_environment__add.sql
@@ -0,0 +1 @@
ALTER TABLE questions ADD COLUMN workspace_environment jsonb not null default '{}'::jsonb;
@@ -0,0 +1 @@
ALTER TABLE questions ADD COLUMN external_grading_environment jsonb not null default '{}'::jsonb;
8 changes: 8 additions & 0 deletions schemas/schemas/infoQuestion.json
Expand Up @@ -136,6 +136,10 @@
"enableNetworking": {
"description": "Whether the grading containers should have network access. Access is disabled by default.",
"type": "boolean"
},
"environment": {
"description": "Environment variables to set inside the grading container.",
"type": "object"
}
}
},
Expand Down Expand Up @@ -269,6 +273,10 @@
"enableNetworking": {
"description": "Whether the workspace should have network access. Access is disabled by default.",
"type": "boolean"
},
"environment": {
"description": "Environment variables to set inside the workspace container.",
"type": "object"
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions sprocs/sync_questions.sql
Expand Up @@ -128,6 +128,7 @@ BEGIN
external_grading_entrypoint = src.data->>'external_grading_entrypoint',
external_grading_timeout = (src.data->>'external_grading_timeout')::integer,
external_grading_enable_networking = (src.data->>'external_grading_enable_networking')::boolean,
external_grading_environment = (src.data->>'external_grading_environment')::jsonb,
dependencies = (src.data->>'dependencies')::jsonb,
workspace_image = src.data->>'workspace_image',
workspace_port = (src.data->>'workspace_port')::integer,
Expand All @@ -137,6 +138,7 @@ BEGIN
workspace_sync_ignore = jsonb_array_to_text_array(src.data->'workspace_sync_ignore'),
workspace_url_rewrite = (src.data->>'workspace_url_rewrite')::boolean,
workspace_enable_networking = (src.data->>'workspace_enable_networking')::boolean,
workspace_environment = (src.data->>'workspace_environment')::jsonb,
sync_errors = NULL,
sync_warnings = src.warnings
FROM
Expand Down
2 changes: 2 additions & 0 deletions sync/course-db.js
Expand Up @@ -243,6 +243,7 @@ const FILE_UUID_REGEX = /"uuid":\s*"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4
* @property {string[]} serverFilesCourse
* @property {number} timeout
* @property {boolean} enableNetworking
* @property {Record<string, string | null>} environment
*/

/**
Expand All @@ -255,6 +256,7 @@ const FILE_UUID_REGEX = /"uuid":\s*"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4
* @property {string[]} syncIgnore
* @property {string} rewriteUrl
* @property {boolean} enableNetworking
* @property {Record<string, string | null>} environment
*/

/**
Expand Down
2 changes: 2 additions & 0 deletions sync/fromDisk/questions.js
Expand Up @@ -36,6 +36,7 @@ function getParamsForQuestion(q) {
external_grading_entrypoint: (q.externalGradingOptions && q.externalGradingOptions.entrypoint),
external_grading_timeout: (q.externalGradingOptions && q.externalGradingOptions.timeout),
external_grading_enable_networking: (q.externalGradingOptions && q.externalGradingOptions.enableNetworking),
external_grading_environment: q.externalGradingOptions?.environment ?? {},
dependencies: q.dependencies || {},
workspace_image: (q.workspaceOptions && q.workspaceOptions.image),
workspace_port: (q.workspaceOptions && q.workspaceOptions.port),
Expand All @@ -45,6 +46,7 @@ function getParamsForQuestion(q) {
workspace_sync_ignore: (q.workspaceOptions && q.workspaceOptions.syncIgnore),
workspace_url_rewrite: (q.workspaceOptions && q.workspaceOptions.rewriteUrl),
workspace_enable_networking: (q.workspaceOptions && q.workspaceOptions.enableNetworking),
workspace_environment: q.workspaceOptions?.environment ?? {},
};
}

Expand Down
9 changes: 7 additions & 2 deletions testCourse/questions/workspace/info.json
Expand Up @@ -4,7 +4,8 @@
"topic": "Workspace",
"tags": [
"su20",
"nnytko2"
"nnytko2",
"tyang15"
],
"type": "v3",
"workspaceOptions": {
Expand All @@ -14,6 +15,10 @@
"gradedFiles": [
"starter_code.h",
"starter_code.c"
]
],
"environment": {
"FOO": "bar baz",
"ILL": "ini"
}
}
}
2 changes: 2 additions & 0 deletions tests/sync/util.js
Expand Up @@ -147,6 +147,7 @@ const syncFromDisk = require('../../sync/syncFromDisk');
* @property {string[]=} serverFilesCourse
* @property {number=} timeout
* @property {boolean=} enableNetworking
* @property {Record<string, string | null>=} environment
*/

/**
Expand All @@ -159,6 +160,7 @@ const syncFromDisk = require('../../sync/syncFromDisk');
* @property {string[]} syncIgnore
* @property {string} rewriteUrl
* @property {boolean=} enableNetworking
* @property {Record<string, string | null>=} environment
*/

/**
Expand Down
13 changes: 9 additions & 4 deletions workspace_host/interface.js
Expand Up @@ -623,6 +623,11 @@ const _checkServerAsync = util.promisify(_checkServer);
*/
async function _getWorkspaceSettingsAsync(workspace_id) {
const result = await sqldb.queryOneRowAsync(sql.select_workspace_settings, { workspace_id });
const workspace_environment = result.rows[0].workspace_environment || {};

// Set base URL needed by certain workspaces (e.g., jupyterlab, rstudio)
workspace_environment['WORKSPACE_BASE_URL'] = `/pl/workspace/${workspace_id}/container/`;

const settings = {
workspace_image: result.rows[0].workspace_image,
workspace_port: result.rows[0].workspace_port,
Expand All @@ -631,6 +636,8 @@ async function _getWorkspaceSettingsAsync(workspace_id) {
workspace_args: result.rows[0].workspace_args || '',
workspace_sync_ignore: result.rows[0].workspace_sync_ignore || [],
workspace_enable_networking: !!result.rows[0].workspace_enable_networking,
// Convert {key: 'value'} to ['key=value'] and {key: null} to ['key'] for Docker API
workspace_environment: Object.entries(workspace_environment).map(([k, v]) => v === null ? k : `${k}=${v}`),
};

if (config.cacheImageRegistry) {
Expand Down Expand Up @@ -984,7 +991,7 @@ function _createContainer(workspace, callback) {
debug(`Exposed port: ${workspace.settings.workspace_port}`);
debug(`Networking enabled: ${workspace.settings.workspace_enable_networking}`);
debug(`Network mode: ${networkMode}`);
debug(`Env vars: WORKSPACE_BASE_URL=/pl/workspace/${workspace.id}/container/`);
debug(`Env vars: ${workspace.settings.workspace_environment}`);
debug(`User binding: ${config.workspaceJobsDirectoryOwnerUid}:${config.workspaceJobsDirectoryOwnerGid}`);
debug(`Port binding: ${workspace.settings.workspace_port}:${workspace.launch_port}`);
debug(`Volume mount: ${workspacePath}:${containerPath}`);
Expand Down Expand Up @@ -1021,9 +1028,7 @@ function _createContainer(workspace, callback) {
ExposedPorts: {
[`${workspace.settings.workspace_port}/tcp`]: {},
},
Env: [
`WORKSPACE_BASE_URL=/pl/workspace/${workspace.id}/container/`,
],
Env: workspace.settings.workspace_environment,
User: `${config.workspaceJobsDirectoryOwnerUid}:${config.workspaceJobsDirectoryOwnerGid}`,
HostConfig: {
PortBindings: {
Expand Down

0 comments on commit 2727ae4

Please sign in to comment.