Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrated code lifecycle: Provide Instructors more options to control container configuration #9487

Open
wants to merge 66 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
5ee5b07
server side
BBesrour Oct 13, 2024
7660c8b
ui
BBesrour Oct 14, 2024
20f6666
ui
BBesrour Oct 15, 2024
b51ef93
ui
BBesrour Oct 15, 2024
3e3895a
server validation
BBesrour Oct 15, 2024
65751ff
ui
BBesrour Oct 15, 2024
8ec3f57
tests
BBesrour Oct 15, 2024
7c98073
tests
BBesrour Oct 15, 2024
08b22d3
coderabbit feedback
BBesrour Oct 16, 2024
566ede9
add warning
BBesrour Oct 16, 2024
b1cd291
fix style
BBesrour Oct 16, 2024
9d6ba04
fix translations
BBesrour Oct 16, 2024
f35a756
feedback
BBesrour Oct 18, 2024
2455bcb
feedback
BBesrour Oct 18, 2024
6702638
feedback
BBesrour Oct 18, 2024
ccb2a2a
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Oct 20, 2024
4fbd6f4
add docs
BBesrour Oct 20, 2024
6d4393c
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Oct 22, 2024
39b62f8
fix tests
BBesrour Oct 22, 2024
b775830
restrict env to 1000chars
BBesrour Oct 22, 2024
41f602b
restrict env to 1000chars
BBesrour Oct 22, 2024
16074b5
restrict env to 1000chars
BBesrour Oct 22, 2024
eb928d4
coderabbit feedback
BBesrour Oct 22, 2024
202c1f8
REVERT
BBesrour Oct 26, 2024
e822566
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Oct 26, 2024
f99006b
REVERTED
BBesrour Oct 26, 2024
112842b
add warning
BBesrour Oct 26, 2024
7fd2909
add warning
BBesrour Oct 26, 2024
d006708
revert merge changes
BBesrour Oct 26, 2024
9efe970
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Oct 26, 2024
9480726
feedback
BBesrour Oct 28, 2024
29b4dba
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Oct 28, 2024
867c1a5
fix vulnerability
BBesrour Oct 30, 2024
a28c26c
fix vulnerability
BBesrour Oct 30, 2024
92069c2
fix vulnerability
BBesrour Oct 30, 2024
c0005d3
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Nov 3, 2024
089b70d
feedback
BBesrour Nov 3, 2024
5a2ee74
feedback
BBesrour Nov 3, 2024
5d8a008
fix tests
BBesrour Nov 3, 2024
fb80482
fix tests
BBesrour Nov 4, 2024
999e5bd
fix bug
BBesrour Nov 4, 2024
3d8e2c0
fix docs
BBesrour Nov 4, 2024
e716b69
feedback
BBesrour Nov 6, 2024
3f5d53e
feedback
BBesrour Nov 6, 2024
497ff9b
feedback
BBesrour Nov 7, 2024
4a8d24f
feedback
BBesrour Nov 10, 2024
c3a3582
feedback
BBesrour Nov 11, 2024
5c253f9
fix tests
BBesrour Nov 11, 2024
2a8709f
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Nov 11, 2024
4902f61
fix tests
BBesrour Nov 11, 2024
4239f33
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Nov 11, 2024
0271f70
remove unnecessary code
BBesrour Nov 11, 2024
c725fd2
update docs
BBesrour Nov 11, 2024
d3a1651
fix translations
BBesrour Nov 11, 2024
bac63a7
fix tests
BBesrour Nov 12, 2024
26f3b38
fix 500 error
BBesrour Nov 12, 2024
a4c2e12
fix tests
BBesrour Nov 12, 2024
5b3802e
fail build if disconnection fails
BBesrour Nov 12, 2024
042437d
remove unused code
BBesrour Nov 12, 2024
a3232c1
feedback
BBesrour Nov 13, 2024
0f504ed
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Nov 26, 2024
42d41dd
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
krusche Nov 29, 2024
fc41a79
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
krusche Nov 29, 2024
37e58a6
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Nov 30, 2024
7276a06
merge conflicts
BBesrour Nov 30, 2024
08db5a4
Merge branch 'develop' into feature/integrated-code-lifecycle/custom-…
BBesrour Dec 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion docs/user/exercises/programming-exercise-setup.inc
Original file line number Diff line number Diff line change
Expand Up @@ -404,14 +404,39 @@ Edit Maximum Build Duration
^^^^^^^^^^^^^^^^^^^^^^^^^^^

**This option is only available when using** :ref:`integrated code lifecycle<integrated code lifecycle>`
This section is optional. In most cases, the preconfigured build script does not need to be changed.

This section is optional. In most cases, the default maximum build duration does not need to be changed.

The maximum build duration is the time limit for the build plan to execute. If the build plan exceeds this time limit, it will be terminated. The default value is 120 seconds.
You can change the maximum build duration by using the slider.

.. figure:: programming/timeout-slider.png
:align: center

Edit Container Configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

**This option is only available when using** :ref:`integrated code lifecycle<integrated code lifecycle>`

This section is optional. In most cases, the default container configuration does not need to be changed.

Currently, instructors can only change whether the container has internet access and add additional environment variables.
Disabling internet access can be useful if instructors want to prevent students from downloading additional dependencies during the build process.
If internet access is disabled, the container cannot access the internet during the build process. Thus, it will not be able to download additional dependencies.
The dependencies must then be included/cached in the docker image.

Additional environment variables can be added to the container configuration. This can be useful if the build process requires additional environment variables to be set.
Keys and values can be wrapped in single or double quotes. The keys and values must be separated by an equal sign. Multiple key-value pairs can be added by separating them with a comma with no space in between.

.. figure:: programming/docker-flags-edit.png
:align: center

We plan to add more options to the container configuration in the future.

.. warning::
- Disabling internet access is not currently supported for Swift and Haskell exercises.


.. _configure_static_code_analysis_tools:

Configure static code analysis
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record BuildConfig(String buildScript, String dockerImage, String commitHashToBuild, String assignmentCommitHash, String testCommitHash, String branch,
ProgrammingLanguage programmingLanguage, ProjectType projectType, boolean scaEnabled, boolean sequentialTestRunsEnabled, boolean testwiseCoverageEnabled,
List<String> resultPaths, int timeoutSeconds, String assignmentCheckoutPath, String testCheckoutPath, String solutionCheckoutPath) implements Serializable {
List<String> resultPaths, int timeoutSeconds, String assignmentCheckoutPath, String testCheckoutPath, String solutionCheckoutPath, DockerRunConfig dockerRunConfig)
implements Serializable {
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

@Override
public String dockerImage() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package de.tum.cit.aet.artemis.buildagent.dto;

import java.io.Serializable;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public record DockerRunConfig(boolean isNetworkDisabled, List<String> env) implements Serializable {
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

public enum AllowedDockerFlags {

NETWORK("network"), ENV("env");

private final String flag;

AllowedDockerFlags(String flag) {
this.flag = flag;
}

public String flag() {
return flag;
}

private static final Set<String> ALLOWED_FLAGS = new HashSet<>();

static {
for (AllowedDockerFlags value : values()) {
ALLOWED_FLAGS.add(value.flag());
}
}

BBesrour marked this conversation as resolved.
Show resolved Hide resolved
public static boolean isAllowed(String flag) {
return ALLOWED_FLAGS.contains(flag);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,23 @@ public BuildJobContainerService(DockerClient dockerClient, HostConfig hostConfig
/**
* Configure a container with the Docker image, the container name, optional proxy config variables, and set the command that runs when the container starts.
*
* @param containerName the name of the container to be created
* @param image the Docker image to use for the container
* @param buildScript the build script to be executed in the container
* @param containerName the name of the container to be created
* @param image the Docker image to use for the container
* @param buildScript the build script to be executed in the container
* @param exerciseEnvVars the environment variables provided by the instructor
* @return {@link CreateContainerResponse} that can be used to start the container
*/
public CreateContainerResponse configureContainer(String containerName, String image, String buildScript) {
public CreateContainerResponse configureContainer(String containerName, String image, String buildScript, List<String> exerciseEnvVars) {
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
List<String> envVars = new ArrayList<>();
if (useSystemProxy) {
envVars.add("HTTP_PROXY=" + httpProxy);
envVars.add("HTTPS_PROXY=" + httpsProxy);
envVars.add("NO_PROXY=" + noProxy);
}
envVars.add("SCRIPT=" + buildScript);
if (exerciseEnvVars != null && !exerciseEnvVars.isEmpty()) {
envVars.addAll(exerciseEnvVars);
}
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
return dockerClient.createContainerCmd(image).withName(containerName).withHostConfig(hostConfig).withEnv(envVars)
// Command to run when the container starts. This is the command that will be executed in the container's main process, which runs in the foreground and blocks the
// container from exiting until it finishes.
Expand All @@ -121,11 +125,21 @@ public void startContainer(String containerId) {
/**
* Run the script in the container and wait for it to finish before returning.
*
* @param containerId the id of the container in which the script should be run
* @param buildJobId the id of the build job that is currently being executed
* @param containerId the id of the container in which the script should be run
* @param buildJobId the id of the build job that is currently being executed
* @param isNetworkDisabled whether the network should be disabled for the container
*/
public void runScriptInContainer(String containerId, String buildJobId, boolean isNetworkDisabled) {
if (isNetworkDisabled) {
log.info("disconnecting container with id {} from network", containerId);
try {
dockerClient.disconnectFromNetworkCmd().withContainerId(containerId).withNetworkId("bridge").exec();
}
catch (Exception e) {
log.error("Failed to disconnect container with id {} from network: {}", containerId, e.getMessage());
}
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
}

public void runScriptInContainer(String containerId, String buildJobId) {
log.info("Started running the build script for build job in container with id {}", containerId);
// The "sh script.sh" execution command specified here is run inside the container as an additional process. This command runs in the background, independent of the
// container's
Expand Down Expand Up @@ -448,9 +462,4 @@ private Container getContainerForName(String containerName) {
List<Container> containers = dockerClient.listContainersCmd().withShowAll(true).exec();
return containers.stream().filter(container -> container.getNames()[0].equals("/" + containerName)).findFirst().orElse(null);
}

private String getParentFolderPath(String path) {
Path parentPath = Paths.get(path).normalize().getParent();
return parentPath != null ? parentPath.toString() : "";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,18 @@ public BuildResult runBuildJob(BuildJobQueueItem buildJob, String containerName)
index++;
}

CreateContainerResponse container = buildJobContainerService.configureContainer(containerName, buildJob.buildConfig().dockerImage(), buildJob.buildConfig().buildScript());
List<String> envVars = null;
boolean isNetworkDisabled = false;
if (buildJob.buildConfig().dockerRunConfig() != null) {
envVars = buildJob.buildConfig().dockerRunConfig().env();
isNetworkDisabled = buildJob.buildConfig().dockerRunConfig().isNetworkDisabled();
}

BBesrour marked this conversation as resolved.
Show resolved Hide resolved
CreateContainerResponse container = buildJobContainerService.configureContainer(containerName, buildJob.buildConfig().dockerImage(), buildJob.buildConfig().buildScript(),
envVars);
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

return runScriptAndParseResults(buildJob, containerName, container.getId(), assignmentRepoUri, testsRepoUri, solutionRepoUri, auxiliaryRepositoriesUris,
assignmentRepositoryPath, testsRepositoryPath, solutionRepositoryPath, auxiliaryRepositoriesPaths, assignmentCommitHash, testCommitHash);
assignmentRepositoryPath, testsRepositoryPath, solutionRepositoryPath, auxiliaryRepositoriesPaths, assignmentCommitHash, testCommitHash, isNetworkDisabled);
}

/**
Expand Down Expand Up @@ -270,7 +278,7 @@ public BuildResult runBuildJob(BuildJobQueueItem buildJob, String containerName)
private BuildResult runScriptAndParseResults(BuildJobQueueItem buildJob, String containerName, String containerId, VcsRepositoryUri assignmentRepositoryUri,
VcsRepositoryUri testRepositoryUri, VcsRepositoryUri solutionRepositoryUri, VcsRepositoryUri[] auxiliaryRepositoriesUris, Path assignmentRepositoryPath,
Path testsRepositoryPath, Path solutionRepositoryPath, Path[] auxiliaryRepositoriesPaths, @Nullable String assignmentRepoCommitHash,
@Nullable String testRepoCommitHash) {
@Nullable String testRepoCommitHash, boolean isNetworkDisabled) {
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

long timeNanoStart = System.nanoTime();

Expand All @@ -292,7 +300,7 @@ private BuildResult runScriptAndParseResults(BuildJobQueueItem buildJob, String
buildLogsMap.appendBuildLogEntry(buildJob.id(), msg);
log.debug(msg);

buildJobContainerService.runScriptInContainer(containerId, buildJob.id());
buildJobContainerService.runScriptInContainer(containerId, buildJob.id(), isNetworkDisabled);

msg = "~~~~~~~~~~~~~~~~~~~~ Finished Executing Build Script for Build job " + buildJob.id() + " ~~~~~~~~~~~~~~~~~~~~";
buildLogsMap.appendBuildLogEntry(buildJob.id(), msg);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public final class Constants {

public static final int QUIZ_GRACE_PERIOD_IN_SECONDS = 5;

public static final int MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH = 1000;
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

/**
* This constant determines how many seconds after the exercise due dates submissions will still be considered rated.
* Submissions after the grace period exceeded will be flagged as illegal.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public class ProgrammingExerciseBuildConfig extends DomainObject {
@Column(name = "timeout_seconds")
private int timeoutSeconds;

@Column(name = "docker_flags")
@Column(name = "docker_flags", columnDefinition = "longtext")
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
private String dockerFlags;

@OneToOne(mappedBy = "buildConfig")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package de.tum.cit.aet.artemis.programming.service;

import static de.tum.cit.aet.artemis.core.config.Constants.MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH;
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import jakarta.annotation.Nullable;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import de.tum.cit.aet.artemis.buildagent.dto.DockerRunConfig;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig;

@Profile(PROFILE_CORE)
@Service
public class ProgrammingExerciseBuildConfigService {

private static final Logger log = org.slf4j.LoggerFactory.getLogger(ProgrammingExerciseBuildConfigService.class);

/**
* Converts a JSON string representing Docker flags (in the form of a list of key-value pairs)
* into a {@link DockerRunConfig} instance.
*
* <p>
* The JSON string is expected to represent a list of key-value pairs where each
* entry is a list containing two strings: the first being the key and the second being the value.
* Example JSON input:
*
* <pre>
* [["network", "none"], ["env", "TEST"]]
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
b-fein marked this conversation as resolved.
Show resolved Hide resolved
* </pre>
*
* @param buildConfig the build config containing the Docker flags
* @return a {@link DockerRunConfig} object initialized with the parsed flags, or {@code null} if an error occurs
*/
@Nullable
public DockerRunConfig getDockerRunConfig(ProgrammingExerciseBuildConfig buildConfig) {
List<List<String>> parsedList = parseDockerFlags(buildConfig);
if (parsedList == null) {
return null;
}
return getDockerRunConfigFromParsedList(parsedList);
}

/**
* Converts a list of key-value pairs representing Docker flags into a {@link DockerRunConfig} instance. @see {@link #getDockerRunConfig(ProgrammingExerciseBuildConfig)}
*
* @param list the list of key-value pairs
* @return a {@link DockerRunConfig} object initialized with the parsed flags, or {@code null} if an error occurs
*/
@Nullable
DockerRunConfig getDockerRunConfigFromParsedList(List<List<String>> list) {
final int keyIndex = 0;
final int valueIndex = 1;
try {
boolean networkDisabled = false;
List<String> env = null;
for (List<String> entry : list) {
if (entry.size() != 2 || StringUtils.isBlank(entry.get(valueIndex)) || StringUtils.isBlank(entry.get(keyIndex))
|| !DockerRunConfig.AllowedDockerFlags.isAllowed(entry.get(keyIndex))) {
log.warn("Invalid Docker flag entry: {}. Skipping.", entry);
continue;
}
switch (entry.get(keyIndex)) {
case "network":
networkDisabled = entry.get(valueIndex).equalsIgnoreCase("none");
break;
case "env":
env = parseEnvVariableString(entry.get(valueIndex));
break;
default:
log.error("Invalid Docker flag entry: {}. Skipping.", entry);
break;
}

}
return new DockerRunConfig(networkDisabled, env);
}
catch (Exception e) {
log.error("Failed to parse DockerRunConfig from JSON string: {}. Using default settings.", list, e);
}

return null;
}

/**
* Parses the JSON string representing Docker flags into a list of key-value pairs.
*
* @return a list of key-value pairs, or {@code null} if an error occurs
*/
@Nullable
List<List<String>> parseDockerFlags(ProgrammingExerciseBuildConfig buildConfig) {
if (StringUtils.isBlank(buildConfig.getDockerFlags())) {
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
return null;
}

try {
ObjectMapper objectMapper = new ObjectMapper();
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

return objectMapper.readValue(buildConfig.getDockerFlags(), new TypeReference<>() {
});
}
catch (Exception e) {
log.error("Failed to parse DockerRunConfig from JSON string: {}. Using default settings.", buildConfig.getDockerFlags(), e);
}

return null;
}

private List<String> parseEnvVariableString(String envVariableString) {
Pattern pattern = Pattern.compile(
// match key-value pairs, where the key can be a single word or a string in single or double quotes
// key-value pairs are separated by commas
"(?:'([^']+)'|\"([^\"]+)\"|(\\w+))=(?:'([^']*)'|\"([^\"]*)\"|([^,]+))");
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

if (envVariableString.length() > MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH) {
log.warn("The environment variables string is too long. It will be truncated to {} characters.", MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH);
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
envVariableString = envVariableString.substring(0, MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH);
}

Matcher matcher = pattern.matcher(envVariableString);
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed

return extractEnvVariablesKeyValues(matcher);
}

/**
* Extracts the key-value pairs from the matcher and returns them as a list of strings
* The key/value can be a single word, a string in single quotes, or a string in double quotes
*
* @param matcher the matcher that contains the key-value pairs
* @return a list of strings containing the key-value pairs
*/
private List<String> extractEnvVariablesKeyValues(Matcher matcher) {
List<String> envVars = new ArrayList<>();
while (matcher.find()) {
// The key can be a single word, a string in single quotes, or a string in double quotes, if matched to group1, the key is in single quotes, if matched to group2, the
// key is in double quotes, otherwise it is a single word
// if all groups are null, the key is a single word
String key = matcher.group(1) != null ? matcher.group(1) : matcher.group(2) != null ? matcher.group(2) : matcher.group(3);

// The value can be a single word, a string in single quotes, or a string in double quotes, if matched to group4, the value is in single quotes, if matched to group5,
// the value is in double quotes, otherwise it is a single word
// if all groups are null, the value is a single word
String value = matcher.group(4) != null ? matcher.group(4) : matcher.group(5) != null ? matcher.group(5) : matcher.group(6);

// Add the key-value pair to the list
envVars.add(key + "=" + value);
}
return envVars;
}
}
Loading
Loading