Skip to content

Commit

Permalink
Support cloning via SSH (#89)
Browse files Browse the repository at this point in the history
* support cloning via SSH

* move from sshd to apache mina (seems to be preferred by jgit)

* remove jsch dependency

* introduce property for jgit version
  • Loading branch information
Feuermagier authored Aug 14, 2024
1 parent 595c27f commit fdc5cc1
Show file tree
Hide file tree
Showing 10 changed files with 221 additions and 21 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ build/
.DS_Store

/.autograder-tmp
/test_content
10 changes: 9 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
<slf4j.version>2.0.16</slf4j.version>
<okhttp.version>4.12.0</okhttp.version>
<autograder.version>0.6.0</autograder.version>
<jgit.version>6.10.0.202406032230-r</jgit.version>
<junit.version>5.11.0-M2</junit.version>
<versions-maven-plugin.version>2.17.1</versions-maven-plugin.version>
</properties>
Expand Down Expand Up @@ -99,10 +100,17 @@
<artifactId>autograder-api</artifactId>
<version>${autograder.version}</version>
</dependency>

<!-- Git -->
<dependency>
<groupId>org.eclipse.jgit</groupId>
<artifactId>org.eclipse.jgit</artifactId>
<version>6.10.0.202406032230-r</version>
<version>${jgit.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jgit</groupId>
<artifactId>org.eclipse.jgit.ssh.apache</artifactId>
<version>${jgit.version}</version>
</dependency>

<!-- Test dependencies -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,18 @@ public String getAPIBaseURL() {
return this.protocol + this.domain + "/api";
}

public String getManagementBaseURL() {
return this.protocol + this.domain + "/management";
}

public HttpUrl url(List<Object> pathComponents, Map<String, Object> queryParams) {
return this.url(pathComponents, queryParams, false);
}

public HttpUrl url(List<Object> pathComponents, Map<String, Object> queryParams, boolean managementRequest) {
String baseUrl = managementRequest ? this.getManagementBaseURL() : this.getAPIBaseURL();
String path = pathComponents.stream().map(Object::toString).collect(Collectors.joining("/"));
var url = HttpUrl.parse(this.getAPIBaseURL() + "/" + path);
var url = HttpUrl.parse(baseUrl + "/" + path);
if (queryParams != null && !queryParams.isEmpty()) {
var builder = url.newBuilder();
queryParams.forEach((p, v) -> builder.addQueryParameter(p, v.toString()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
public class ArtemisRequest {
private final String method;
private List<Object> path;
private boolean managementRequest = false;
private final Map<String, Object> requestParams = new HashMap<>();
private Object body;

Expand Down Expand Up @@ -40,6 +41,18 @@ public ArtemisRequest path(List<Object> path) {
return this;
}

/**
*
* @param managementRequest whether the request shall be performed against
* Artemis' "normal" API (/api), or against the
* management API (/management)
* @return
*/
public ArtemisRequest managementRequest(boolean managementRequest) {
this.managementRequest = managementRequest;
return this;
}

public <E> ArtemisRequest body(E entity) {
if (this.method.equals("GET")) {
throw new IllegalArgumentException("GET requests cannot have a body");
Expand All @@ -63,7 +76,7 @@ public <R> R executeAndDecode(ArtemisClient client, Class<R> resultClass) throws
request.method(this.method, ArtemisClient.encodeJSON(this.body));
}

request.url(client.getInstance().url(this.path, this.requestParams));
request.url(client.getInstance().url(this.path, this.requestParams, this.managementRequest));
return client.call(request.build(), resultClass);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* Licensed under EPL-2.0 2024. */
package edu.kit.kastel.sdq.artemis4j.client;

import java.util.List;

import com.fasterxml.jackson.annotation.JsonProperty;
import edu.kit.kastel.sdq.artemis4j.ArtemisNetworkException;

/**
* There are many more fields in the actual entity that are added as needed
*
* @param sshCloneURLTemplate Base URL for cloning repositories via SSH
*/
public record ManagementInfoDTO(@JsonProperty String sshCloneURLTemplate) {
public static ManagementInfoDTO fetch(ArtemisClient client) throws ArtemisNetworkException {
return ArtemisRequest.get().path(List.of("info")).managementRequest(true).executeAndDecode(client, ManagementInfoDTO.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

public record UserDTO(@JsonProperty long id, @JsonProperty String login, @JsonProperty String firstName, @JsonProperty String lastName,
@JsonProperty String email, @JsonProperty boolean activated, @JsonProperty String langKey, @JsonProperty String lastNotificationRead,
@JsonProperty String name, @JsonProperty String participantIdentifier, @JsonProperty List<String> groups, @JsonProperty String vcsAccessToken) {
@JsonProperty String name, @JsonProperty String participantIdentifier, @JsonProperty List<String> groups, @JsonProperty String vcsAccessToken,
@JsonProperty String sshPublicKey) {
public UserDTO {
if (groups == null) {
// when a user is not in any group, artemis returns null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import edu.kit.kastel.sdq.artemis4j.client.ArtemisClient;
import edu.kit.kastel.sdq.artemis4j.client.ArtemisInstance;
import edu.kit.kastel.sdq.artemis4j.client.CourseDTO;
import edu.kit.kastel.sdq.artemis4j.client.ManagementInfoDTO;
import edu.kit.kastel.sdq.artemis4j.client.UserDTO;

/**
Expand All @@ -17,6 +18,7 @@
*/
public final class ArtemisConnection {
private final ArtemisClient client;
private final LazyNetworkValue<ManagementInfoDTO> managementInfo;
private final LazyNetworkValue<User> assessor;
private final LazyNetworkValue<List<Course>> courses;

Expand All @@ -30,6 +32,7 @@ public static ArtemisConnection fromToken(ArtemisInstance instance, String token

public ArtemisConnection(ArtemisClient client) {
this.client = client;
this.managementInfo = new LazyNetworkValue<>(() -> ManagementInfoDTO.fetch(this.client));
this.assessor = new LazyNetworkValue<>(() -> new User(UserDTO.getAssessingUser(this.client)));
this.courses = new LazyNetworkValue<>(() -> CourseDTO.fetchAll(this.client).stream().map(dto -> new Course(dto, this)).toList());
}
Expand All @@ -38,6 +41,10 @@ public ArtemisClient getClient() {
return client;
}

public ManagementInfoDTO getManagementInfo() throws ArtemisNetworkException {
return managementInfo.get();
}

public User getAssessor() throws ArtemisNetworkException {
return assessor.get();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,56 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Optional;

import javax.swing.BoxLayout;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;

import edu.kit.kastel.sdq.artemis4j.ArtemisClientException;
import edu.kit.kastel.sdq.artemis4j.ArtemisNetworkException;
import org.eclipse.jgit.api.CloneCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.errors.UnsupportedCredentialItem;
import org.eclipse.jgit.transport.CredentialItem;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.SshSessionFactory;
import org.eclipse.jgit.transport.SshTransport;
import org.eclipse.jgit.transport.URIish;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;

import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder;
import org.eclipse.jgit.util.FS;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Represents a submission and associated tests repository that has been cloned
* to a local folder.
* <p>
* Most of the logic in this class is required to support cloning via SSH.
*/
public class ClonedProgrammingSubmission implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(ClonedProgrammingSubmission.class);
private final ProgrammingSubmission submission;
private final Path testsPath;
private final Path submissionPath;

static ClonedProgrammingSubmission cloneSubmission(ProgrammingSubmission submission, Path target, String tokenOverride) throws ArtemisClientException {
var connection = submission.getConnection();

// Cache credentials between both clones
var credentialsProvider = buildCredentialsProvider(tokenOverride, connection);

// Clone the test repository
cloneRepositoryInto(submission.getExercise().getTestRepositoryUrl(), target, tokenOverride, connection);
cloneRepositoryInto(submission.getExercise().getTestRepositoryUrl(), target, credentialsProvider, connection);

// Clone the student's submission into a subfolder
Path submissionPath = target.resolve("assignment");
cloneRepositoryInto(submission.getRepositoryUrl(), submissionPath, tokenOverride, connection);
cloneRepositoryInto(submission.getRepositoryUrl(), submissionPath, credentialsProvider, connection);

// Check out the submitted commit
try (var repo = Git.open(submissionPath.toFile())) {
Expand Down Expand Up @@ -59,25 +89,59 @@ public Path getSubmissionSourcePath() {
return submissionPath.resolve("src");
}

private static void cloneRepositoryInto(String repositoryURL, Path target, String tokenOverride, ArtemisConnection connection)
throws ArtemisClientException {
private static CredentialsProvider buildCredentialsProvider(String tokenOverride, ArtemisConnection connection) throws ArtemisNetworkException {
var assessor = connection.getAssessor();

String username = assessor.getLogin();
String token;
if (tokenOverride != null) {
token = tokenOverride;
} else if (assessor.getGitToken().isPresent()) {
token = assessor.getGitToken().get();
} else if (connection.getClient().getPassword().isPresent()) {
token = connection.getClient().getPassword().get();
if (tokenOverride == null && assessor.getGitSSHKey().isPresent()) {
return new InteractiveCredentialsProvider();
} else {
token = "";
String token;
if (tokenOverride != null) {
token = tokenOverride;
} else if (assessor.getGitToken().isPresent()) {
token = assessor.getGitToken().get();
} else if (connection.getClient().getPassword().isPresent()) {
token = connection.getClient().getPassword().get();
} else {
token = "";
}
return new UsernamePasswordCredentialsProvider(assessor.getLogin(), token);
}
}

private static void cloneRepositoryInto(String repositoryURL, Path target, CredentialsProvider credentialsProvider, ArtemisConnection connection)
throws ArtemisClientException {
var assessor = connection.getAssessor();

CloneCommand cloneCommand = Git.cloneRepository().setDirectory(target.toAbsolutePath().toFile()).setRemote("origin").setURI(repositoryURL)
.setCloneAllBranches(true).setCloneSubmodules(false).setCredentialsProvider(credentialsProvider);

try {
Git.cloneRepository().setDirectory(target.toAbsolutePath().toFile()).setRemote("origin").setURI(repositoryURL).setCloneAllBranches(true)
.setCloneSubmodules(false).setCredentialsProvider(new UsernamePasswordCredentialsProvider(username, token)).call().close();
if (assessor.getGitSSHKey().isPresent()) {
String sshTemplate = connection.getManagementInfo().sshCloneURLTemplate();
if (sshTemplate == null) {
throw new IllegalStateException("SSH key is set, but the Artemis instance does not support SSH cloning");
}

String sshUrl = createSSHUrl(repositoryURL, sshTemplate);
log.info("Cloning repository via SSH from {}", sshUrl);

var sshdFactoryBuilder = new SshdSessionFactoryBuilder().setHomeDirectory(FS.DETECTED.userHome())
.setSshDirectory(new File(FS.DETECTED.userHome(), "/.ssh")).setPreferredAuthentications("publickey");

try (var sshdFactory = sshdFactoryBuilder.build(null)) {
SshSessionFactory.setInstance(sshdFactory);
cloneCommand.setTransportConfigCallback((transport -> {
if (transport instanceof SshTransport sshTransport) {
sshTransport.setSshSessionFactory(sshdFactory);
}
})).setURI(sshUrl).call().close();
}
} else {
log.info("Cloning repository via HTTPS from {}", repositoryURL);
cloneCommand.setURI(repositoryURL).call().close();
}

} catch (GitAPIException e) {
throw new ArtemisClientException("Failed to clone the submission repository", e);
}
Expand All @@ -97,4 +161,79 @@ private static void deleteDirectory(Path path) throws IOException {
dirStream.map(Path::toFile).sorted(Comparator.reverseOrder()).forEach(File::delete);
}
}

private static String createSSHUrl(String url, String sshTemplate) {
// Based on Artemis' getSshCloneUrl method
// https://github.com/ls1intum/Artemis/blob/eb5b9bd4321d953217e902868ac9f38de6dd6c6f/src/main/webapp/app/shared/components/code-button/code-button.component.ts#L174
return url.replaceAll("^\\w*://[^/]*?/(scm/)?(.*)$", sshTemplate + "$2");
}

private static final class PasswordPanel extends JPanel {
private final JPasswordField passwordField = new JPasswordField();

public PasswordPanel(String prompt) {
super();
this.setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
this.add(new JLabel(prompt));
this.add(passwordField);
}

public static Optional<String> show(String title, String prompt) {
PasswordPanel panel = new PasswordPanel(prompt);
JOptionPane pane = new JOptionPane(panel, JOptionPane.QUESTION_MESSAGE, JOptionPane.OK_CANCEL_OPTION) {
@Override
public void selectInitialValue() {
panel.passwordField.requestFocusInWindow();
}
};
JDialog dialog = pane.createDialog(title);
dialog.setVisible(true);

if (pane.getValue() != null && pane.getValue().equals(JOptionPane.OK_OPTION)) {
return Optional.of(new String(panel.passwordField.getPassword()));
}
return Optional.empty();
}
}

private static final class InteractiveCredentialsProvider extends CredentialsProvider {
private String passphrase;

@Override
public boolean isInteractive() {
return true;
}

@Override
public boolean supports(CredentialItem... items) {
return true;
}

@Override
public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem {
for (var item : items) {
if (item instanceof CredentialItem.YesNoType yesNoItem) {
int result = JOptionPane.showConfirmDialog(null, yesNoItem.getPromptText(), "Clone via SSH", JOptionPane.YES_NO_CANCEL_OPTION);
switch (result) {
case JOptionPane.YES_OPTION -> yesNoItem.setValue(true);
case JOptionPane.NO_OPTION -> yesNoItem.setValue(false);
case JOptionPane.CANCEL_OPTION -> {
return false;
}
}
} else if (item instanceof CredentialItem.Password passwordItem) {
if (this.passphrase == null) {
this.passphrase = PasswordPanel.show("Clone via SSH", passwordItem.getPromptText()).orElse(null);
}

if (this.passphrase != null) {
passwordItem.setValueNoCopy(passphrase.toCharArray());
} else {
return false;
}
}
}
return true;
}
}
}
4 changes: 4 additions & 0 deletions src/main/java/edu/kit/kastel/sdq/artemis4j/grading/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ public Optional<String> getGitToken() {
return Optional.ofNullable(this.dto.vcsAccessToken());
}

public Optional<String> getGitSSHKey() {
return Optional.ofNullable(this.dto.sshPublicKey());
}

public List<String> getGroups() {
return Collections.unmodifiableList(this.dto.groups());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ void testLogin() throws ArtemisClientException, IOException {
// Fetch all courses, and get the first one (not the course with id 0!)
// Network requests are generally only performed once when required, and the
// results are cached
var course = connection.getCourses().get(0);
var course = connection.getCourseById(9);
System.out.println("Course is " + course.getTitle());

// Check how many locks we hold across the entire course
Expand Down

0 comments on commit fdc5cc1

Please sign in to comment.