Skip to content

Commit

Permalink
Integrated code lifecycle: Provide Instructors more options to contro…
Browse files Browse the repository at this point in the history
…l container configuration (#9487)
  • Loading branch information
BBesrour authored Dec 3, 2024
1 parent 75c080a commit 3b8d5f1
Show file tree
Hide file tree
Showing 31 changed files with 530 additions and 38 deletions.
26 changes: 25 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,38 @@ 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.

.. 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 {

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

import java.util.Map;

public record DockerFlagsDTO(String network, Map<String, String> env) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.tum.cit.aet.artemis.buildagent.dto;

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

public record DockerRunConfig(boolean isNetworkDisabled, List<String> env) implements Serializable {
}
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) {
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);
}
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,23 @@ 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());
buildLogsMap.appendBuildLogEntry(buildJobId, "Failed to disconnect container from default network 'bridge': " + e.getMessage());
throw new LocalCIException("Failed to disconnect container from default network 'bridge': " + e.getMessage());
}
}

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 +464,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();
}

CreateContainerResponse container = buildJobContainerService.configureContainer(containerName, buildJob.buildConfig().dockerImage(), buildJob.buildConfig().buildScript(),
envVars);

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) {

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;

/**
* 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")
private String dockerFlags;

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

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

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

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.databind.ObjectMapper;

import de.tum.cit.aet.artemis.buildagent.dto.DockerFlagsDTO;
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);

private final ObjectMapper objectMapper = new ObjectMapper();

/**
* Converts a JSON string representing Docker flags (in JSON format)
* into a {@link DockerRunConfig} instance.
*
* <p>
* The JSON string is expected to represent a {@link DockerFlagsDTO} object.
* Example JSON input:
*
* <pre>
* {"network":"none","env":{"key1":"value1","key2":"value2"}}
* </pre>
*
* @param buildConfig the build config containing the Docker flags
* @return a {@link DockerRunConfig} object initialized with the parsed flags, or {@code null} if the JSON string is empty
*/
@Nullable
public DockerRunConfig getDockerRunConfig(ProgrammingExerciseBuildConfig buildConfig) {
DockerFlagsDTO dockerFlagsDTO = parseDockerFlags(buildConfig);

return getDockerRunConfigFromParsedFlags(dockerFlagsDTO);
}

DockerRunConfig getDockerRunConfigFromParsedFlags(DockerFlagsDTO dockerFlagsDTO) {
if (dockerFlagsDTO == null) {
return null;
}
List<String> env = new ArrayList<>();
boolean isNetworkDisabled = dockerFlagsDTO.network() != null && dockerFlagsDTO.network().equals("none");

if (dockerFlagsDTO.env() != null) {
for (Map.Entry<String, String> entry : dockerFlagsDTO.env().entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
env.add(key + "=" + value);
}
}

return new DockerRunConfig(isNetworkDisabled, env);
}

/**
* Parses the JSON string representing Docker flags into DockerFlagsDTO. (see {@link DockerFlagsDTO})
*
* @return a list of key-value pairs, or {@code null} if the JSON string is empty
* @throws IllegalArgumentException if the JSON string is invalid
*/
@Nullable
DockerFlagsDTO parseDockerFlags(ProgrammingExerciseBuildConfig buildConfig) {
if (StringUtils.isBlank(buildConfig.getDockerFlags())) {
return null;
}

try {
return objectMapper.readValue(buildConfig.getDockerFlags(), DockerFlagsDTO.class);
}
catch (Exception e) {
log.error("Failed to parse DockerRunConfig from JSON string: {}. Using default settings.", buildConfig.getDockerFlags());
throw new IllegalArgumentException("Failed to parse DockerRunConfig from JSON string: " + buildConfig.getDockerFlags(), e);
}
}
}
Loading

0 comments on commit 3b8d5f1

Please sign in to comment.