Skip to content

Commit

Permalink
Support cloning via VCS tokens (#102)
Browse files Browse the repository at this point in the history
* support cloning via VCS tokens

* refresh tokens if they expire within two days, create tokens with a default expiry date of today + 6 months
  • Loading branch information
Feuermagier authored Sep 11, 2024
1 parent 7b35e68 commit 4c3337e
Show file tree
Hide file tree
Showing 12 changed files with 329 additions and 167 deletions.
17 changes: 14 additions & 3 deletions src/main/java/edu/kit/kastel/sdq/artemis4j/LazyNetworkValue.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,35 @@

public class LazyNetworkValue<T> {
private final NetworkSupplier<T> supplier;
// Must be volatile to avoid reordering the checks in get()
// Must be volatile to avoid reordering of the checks in get()
private volatile T value;

public LazyNetworkValue(NetworkSupplier<T> supplier) {
this.supplier = supplier;
}

public T get() throws ArtemisNetworkException {
// Store the value in a local variable to avoid invalidation between the null check and the return
T localValue = this.value;

// First un-synchronized check
if (this.value == null) {
if (localValue == null) {
synchronized (this) {
// Second synchronized check to avoid double initialization
if (this.value == null) {
this.value = this.supplier.get();
localValue = this.value;
}
}
}
return this.value;

return localValue;
}

public void invalidate() {
synchronized (this) {
this.value = null;
}
}

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

import java.time.ZonedDateTime;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonProperty;
Expand All @@ -19,6 +20,7 @@ public record UserDTO(
@JsonProperty String participantIdentifier,
@JsonProperty List<String> groups,
@JsonProperty String vcsAccessToken,
@JsonProperty ZonedDateTime vcsAccessTokenExpiryDate,
@JsonProperty String sshPublicKey) {
public UserDTO {
if (groups == null) {
Expand All @@ -32,4 +34,11 @@ public record UserDTO(
public static UserDTO getAssessingUser(ArtemisClient client) throws ArtemisNetworkException {
return ArtemisRequest.get().path(List.of("public", "account")).executeAndDecode(client, UserDTO.class);
}

public static void createVCSToken(ZonedDateTime expiryDate, ArtemisClient client) throws ArtemisNetworkException {
ArtemisRequest.put()
.path(List.of("account", "user-vcs-access-token"))
.param("expiryDate", expiryDate)
.execute(client);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ public User getAssessor() throws ArtemisNetworkException {
return assessor.get();
}

public User refreshAssessor() throws ArtemisNetworkException {
assessor.invalidate();
return getAssessor();
}

public List<Course> getCourses() throws ArtemisNetworkException {
return Collections.unmodifiableList(courses.get());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,14 @@
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 edu.kit.kastel.sdq.artemis4j.grading.git.CloningStrategy;
import edu.kit.kastel.sdq.artemis4j.grading.git.SSHCloningStrategy;
import edu.kit.kastel.sdq.artemis4j.grading.git.VCSTokenCloningStrategy;
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;

Expand All @@ -44,19 +29,28 @@ public class ClonedProgrammingSubmission implements AutoCloseable {
private final Path testsPath;
private final Path submissionPath;

static ClonedProgrammingSubmission cloneSubmission(
static ClonedProgrammingSubmission cloneSubmissionViaSSH(ProgrammingSubmission submission, Path target)
throws ArtemisClientException {
var strategy = new SSHCloningStrategy(submission.getConnection());
return cloneSubmissionInternal(submission, target, strategy);
}

static ClonedProgrammingSubmission cloneSubmissionViaVCSToken(
ProgrammingSubmission submission, Path target, String tokenOverride) throws ArtemisClientException {
var connection = submission.getConnection();
var strategy = new VCSTokenCloningStrategy(tokenOverride, submission.getConnection());
return cloneSubmissionInternal(submission, target, strategy);
}

// Cache credentials between both clones
var credentialsProvider = buildCredentialsProvider(tokenOverride, connection);
private static ClonedProgrammingSubmission cloneSubmissionInternal(
ProgrammingSubmission submission, Path target, CloningStrategy strategy) throws ArtemisClientException {
var connection = submission.getConnection();

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

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

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

private static CredentialsProvider buildCredentialsProvider(String tokenOverride, ArtemisConnection connection)
throws ArtemisNetworkException {
var assessor = connection.getAssessor();

if (tokenOverride == null && assessor.getGitSSHKey().isPresent()) {
return new InteractiveCredentialsProvider();
} else {
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)
String repositoryURL, Path target, CloningStrategy strategy, 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);
.setCloneSubmodules(false);

try {
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();
}

strategy.performClone(repositoryURL, cloneCommand, connection);
} catch (GitAPIException e) {
throw new ArtemisClientException("Failed to clone the submission repository", e);
}
Expand All @@ -176,81 +115,4 @@ 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,26 @@ public int getCorrectionRound() {

/**
* Clones the submission, including the test repository, into the given path,
* and checks out the submitted commit.
* and checks out the submitted commit. This method uses the user's VCS access token, potentially creating a new one.
*
* @param target The path to clone into
* @param tokenOverride (optional) The git password to use for cloning
* @param tokenOverride (optional) The git password to use for cloning. If not set, the PAT is used (and created if necessary)
* @return The path to the actual submission within the target location
*/
public ClonedProgrammingSubmission cloneInto(Path target, String tokenOverride) throws ArtemisClientException {
return ClonedProgrammingSubmission.cloneSubmission(this, target, tokenOverride);
public ClonedProgrammingSubmission cloneViaVCSTokenInto(Path target, String tokenOverride)
throws ArtemisClientException {
return ClonedProgrammingSubmission.cloneSubmissionViaVCSToken(this, target, tokenOverride);
}

/**
* Clones the submission, including the test repository, into the given path,
* and checks out the submitted commit. This method uses the user's SSH key, and may interactively prompt the user for their SSH key passphrase.
*
* @param target The path to clone into
* @return The path to the actual submission within the target location
*/
public ClonedProgrammingSubmission cloneViaSSHInto(Path target) throws ArtemisClientException {
return ClonedProgrammingSubmission.cloneSubmissionViaSSH(this, target);
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/edu/kit/kastel/sdq/artemis4j/grading/User.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* Licensed under EPL-2.0 2024. */
package edu.kit.kastel.sdq.artemis4j.grading;

import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -30,6 +31,10 @@ public Optional<String> getGitToken() {
return Optional.ofNullable(this.dto.vcsAccessToken());
}

public Optional<ZonedDateTime> getGitTokenExpiryDate() {
return Optional.ofNullable(this.dto.vcsAccessTokenExpiryDate());
}

public Optional<String> getGitSSHKey() {
return Optional.ofNullable(this.dto.sshPublicKey());
}
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.grading.git;

import edu.kit.kastel.sdq.artemis4j.ArtemisNetworkException;
import edu.kit.kastel.sdq.artemis4j.grading.ArtemisConnection;
import org.eclipse.jgit.api.CloneCommand;
import org.eclipse.jgit.api.errors.GitAPIException;

public interface CloningStrategy {
/**
* Clones a repository using the given command and connection.
*
* @param repositoryUrl The (HTTPS) URL of the repository to clone.
* @param command Preconfigured clone command. Strategy implementations must set the URI and everything authentication-related.
*/
void performClone(String repositoryUrl, CloneCommand command, ArtemisConnection connection)
throws ArtemisNetworkException, GitAPIException;
}
Loading

0 comments on commit 4c3337e

Please sign in to comment.