diff --git a/.gitignore b/.gitignore index 9515391b..2989fb2d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,14 +3,13 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ -### Plugins Directory ### -test-plugins - ### Log file ### logs/ ### IntelliJ IDEA ### .idea/ +!.idea/runConfigurations/ +!.idea/.gitignore/ *.iws *.iml *.ipr @@ -34,6 +33,8 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ +!**/test/resources/**/.git + ### VS Code ### .vscode/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/Jenkinsfile b/Jenkinsfile index 438a9b51..0af57689 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,8 +1,11 @@ +@Library('pipeline-library@pull/893/head') _ + // While this isn't a plugin, it is much simpler to reuse the pipeline code for CI // allowing easy windows / linux testing and producing incrementals // the only feature that buildPlugin has that relates to plugins is allowing you to test against multiple jenkins versions buildPlugin( useContainerAgent: false, + useArtifactCachingProxy: false, configurations: [ [platform: 'linux', jdk: 21], [platform: 'windows', jdk: 21], diff --git a/README.md b/README.md index 86c4038b..9e99445a 100644 --- a/README.md +++ b/README.md @@ -104,13 +104,13 @@ From there you need to save both ID of installation (found on URL) ## Global option -- `--debug` or `-d`: (optional) Enables debug mode. Defaults to false. +- `--debug`: (optional) Enables debug mode. Defaults to false. -- `--cache-path` or `-c`: (optional) Custom path to the cache directory. Defaults to `${user.home}/.cache/jenkins-plugin-modernizer-cli`. +- `--cache-path`: (optional) Custom path to the cache directory. Defaults to `${user.home}/.cache/jenkins-plugin-modernizer-cli`. -- `--maven-home` or `-m`: (optional) Path to the Maven home directory. Required if both `MAVEN_HOME` and `M2_HOME` environment variables are not set. The minimum required version is 3.9.7. +- `--maven-home`: (optional) Path to the Maven home directory. Required if both `MAVEN_HOME` and `M2_HOME` environment variables are not set. The minimum required version is 3.9.7. - `--clean-local-data` (optional) Deletes the local plugin directory before running the tool. diff --git a/plugin-modernizer-cli/pom.xml b/plugin-modernizer-cli/pom.xml index 3008e95c..96f2ad77 100644 --- a/plugin-modernizer-cli/pom.xml +++ b/plugin-modernizer-cli/pom.xml @@ -122,6 +122,7 @@ **/*ITCase.java + ${maven.repo.local} ${project.build.directory}/apache-maven-${maven.version} fake-token fake-owner diff --git a/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/command/BuildMetadataCommand.java b/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/command/BuildMetadataCommand.java index a50034e2..adb66acf 100644 --- a/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/command/BuildMetadataCommand.java +++ b/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/command/BuildMetadataCommand.java @@ -6,6 +6,8 @@ import io.jenkins.tools.pluginmodernizer.core.config.Config; import io.jenkins.tools.pluginmodernizer.core.config.Settings; import io.jenkins.tools.pluginmodernizer.core.impl.PluginModernizer; +import io.jenkins.tools.pluginmodernizer.core.model.ModernizerException; +import java.nio.file.Path; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import picocli.CommandLine; @@ -27,6 +29,14 @@ public class BuildMetadataCommand implements ICommand { @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") private PluginOptions pluginOptions; + /** + * Path to the authentication key in case of private repo + */ + @CommandLine.Option( + names = {"--ssh-private-key"}, + description = "Path to the authentication key for GitHub. Default to ~/.ssh/id_rsa") + private Path sshPrivateKey = Settings.SSH_PRIVATE_KEY; + /** * Global options for all commands */ @@ -44,12 +54,21 @@ public Config setup(Config.Builder builder) { options.config(builder); envOptions.config(builder); pluginOptions.config(builder); - return builder.withRecipe(Settings.FETCH_METADATA_RECIPE).build(); + return builder.withSshPrivateKey(sshPrivateKey) + .withRecipe(Settings.FETCH_METADATA_RECIPE) + .build(); } @Override - public Integer call() throws Exception { + public Integer call() { PluginModernizer modernizer = getModernizer(); + try { + modernizer.validate(); + } catch (ModernizerException e) { + LOG.error("Validation error"); + LOG.error(e.getMessage()); + return 1; + } modernizer.start(); return 0; } diff --git a/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/command/ValidateCommand.java b/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/command/ValidateCommand.java index 4f8ab5f9..97473f7e 100644 --- a/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/command/ValidateCommand.java +++ b/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/command/ValidateCommand.java @@ -6,6 +6,8 @@ import io.jenkins.tools.pluginmodernizer.core.config.Config; import io.jenkins.tools.pluginmodernizer.core.impl.PluginModernizer; import io.jenkins.tools.pluginmodernizer.core.model.ModernizerException; +import java.nio.file.Files; +import java.nio.file.Path; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import picocli.CommandLine; @@ -53,7 +55,13 @@ public Integer call() throws Exception { try { modernizer.validate(); LOG.info("GitHub owner: {}", modernizer.getGithubOwner()); + if (Files.isRegularFile(Path.of(modernizer.getSshPrivateKeyPath()))) { + LOG.info("SSH key path: {}", modernizer.getSshPrivateKeyPath()); + } else { + LOG.info("SSH key not set. Will use GitHub token for Git operation"); + } LOG.info("Maven home: {}", modernizer.getMavenHome()); + LOG.info("Maven local repository: {}", modernizer.getMavenLocalRepo()); LOG.info("Maven version: {}", modernizer.getMavenVersion()); LOG.info("Java version: {}", modernizer.getJavaVersion()); LOG.info("Cache path: {}", modernizer.getCachePath()); diff --git a/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/options/GitHubOptions.java b/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/options/GitHubOptions.java index 73d9c695..0eda3765 100644 --- a/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/options/GitHubOptions.java +++ b/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/options/GitHubOptions.java @@ -2,6 +2,7 @@ import io.jenkins.tools.pluginmodernizer.core.config.Config; import io.jenkins.tools.pluginmodernizer.core.config.Settings; +import java.nio.file.Path; import picocli.CommandLine; /** @@ -15,6 +16,14 @@ commandListHeading = "%nCommands:%n") public class GitHubOptions implements IOption { + /** + * Path to the authentication key + */ + @CommandLine.Option( + names = {"--ssh-private-key"}, + description = "Path to the authentication key for GitHub. Default to ~/.ssh/id_rsa") + private Path sshPrivateKey = Settings.SSH_PRIVATE_KEY; + @CommandLine.Option( names = {"-g", "--github-owner"}, description = "GitHub owner for forked repositories.") @@ -46,6 +55,7 @@ public void config(Config.Builder builder) { builder.withGitHubOwner(githubOwner) .withGitHubAppId(githubAppId) .withGitHubAppSourceInstallationId(githubAppSourceInstallationId) - .withGitHubAppTargetInstallationId(githubAppTargetInstallationId); + .withGitHubAppTargetInstallationId(githubAppTargetInstallationId) + .withSshPrivateKey(sshPrivateKey); } } diff --git a/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/options/GlobalOptions.java b/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/options/GlobalOptions.java index 9bbd27b4..4b76eaf9 100644 --- a/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/options/GlobalOptions.java +++ b/plugin-modernizer-cli/src/main/java/io/jenkins/tools/pluginmodernizer/cli/options/GlobalOptions.java @@ -20,20 +20,25 @@ public class GlobalOptions implements IOption { @CommandLine.Option( - names = {"-d", "--debug"}, + names = {"--debug"}, description = "Enable debug logging.") public boolean debug; @CommandLine.Option( - names = {"-c", "--cache-path"}, + names = {"--cache-path"}, description = "Path to the cache directory.") public Path cachePath = Settings.DEFAULT_CACHE_PATH; @CommandLine.Option( - names = {"-m", "--maven-home"}, + names = {"--maven-home"}, description = "Path to the Maven Home directory.") public Path mavenHome = Settings.DEFAULT_MAVEN_HOME; + @CommandLine.Option( + names = {"--maven-local-repo"}, + description = "Path to the Maven local repository.") + public Path mavenLocalRepo = Settings.DEFAULT_MAVEN_LOCAL_REPO; + /** * Create a new config build for the global options */ @@ -45,7 +50,8 @@ public void config(Config.Builder builder) { !cachePath.endsWith(Settings.CACHE_SUBDIR) ? cachePath.resolve(Settings.CACHE_SUBDIR) : cachePath) - .withMavenHome(mavenHome); + .withMavenHome(mavenHome) + .withMavenLocalRepo(mavenLocalRepo); } /** diff --git a/plugin-modernizer-cli/src/main/resources/logback.xml b/plugin-modernizer-cli/src/main/resources/logback.xml index 5cfcf798..44885f6f 100644 --- a/plugin-modernizer-cli/src/main/resources/logback.xml +++ b/plugin-modernizer-cli/src/main/resources/logback.xml @@ -43,8 +43,12 @@ - - + + + + + + diff --git a/plugin-modernizer-cli/src/test/java/io/jenkins/tools/pluginmodernizer/cli/CommandLineITCase.java b/plugin-modernizer-cli/src/test/java/io/jenkins/tools/pluginmodernizer/cli/CommandLineITCase.java index 8e9d982f..050fa254 100644 --- a/plugin-modernizer-cli/src/test/java/io/jenkins/tools/pluginmodernizer/cli/CommandLineITCase.java +++ b/plugin-modernizer-cli/src/test/java/io/jenkins/tools/pluginmodernizer/cli/CommandLineITCase.java @@ -5,31 +5,40 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.github.sparsick.testcontainers.gitserver.GitServerVersions; -import com.github.sparsick.testcontainers.gitserver.http.GitHttpServerContainer; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; -import io.jenkins.tools.pluginmodernizer.core.model.HealthScoreData; -import io.jenkins.tools.pluginmodernizer.core.model.PluginVersionData; -import io.jenkins.tools.pluginmodernizer.core.model.UpdateCenterData; +import io.jenkins.tools.pluginmodernizer.cli.utils.GitHubServerContainer; +import io.jenkins.tools.pluginmodernizer.core.extractor.PluginMetadata; +import io.jenkins.tools.pluginmodernizer.core.impl.CacheManager; +import io.jenkins.tools.pluginmodernizer.core.model.Plugin; +import io.jenkins.tools.pluginmodernizer.core.utils.JsonUtils; import java.io.File; +import java.io.FileWriter; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.Map; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Security; import java.util.Properties; +import java.util.stream.Stream; import org.apache.maven.shared.invoker.DefaultInvocationRequest; import org.apache.maven.shared.invoker.DefaultInvoker; import org.apache.maven.shared.invoker.InvocationRequest; import org.apache.maven.shared.invoker.InvocationResult; import org.apache.maven.shared.invoker.Invoker; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.org.bouncycastle.jce.provider.BouncyCastleProvider; /** * Integration test for the command line interface @@ -38,20 +47,37 @@ @Testcontainers(disabledWithoutDocker = true) public class CommandLineITCase { - @Container - private GitHttpServerContainer gitRemote = new GitHttpServerContainer(GitServerVersions.V2_45.getDockerImageName()); + static { + Security.addProvider(new BouncyCastleProvider()); + } /** * Logger */ private static final Logger LOG = LoggerFactory.getLogger(CommandLineITCase.class); + /** + * Tests plugins + * @return the plugins + */ + private static Stream testsPlugins() { + return Stream.of(Arguments.of(new PluginMetadata() { + { + setPluginName("empty"); + setJenkinsVersion("2.440.3"); + } + })); + } + @TempDir private Path outputPath; @TempDir private Path cachePath; + @TempDir + private Path keysPath; + @Test public void testVersion() throws Exception { LOG.info("Running testVersion"); @@ -100,6 +126,32 @@ public void testCleanup() throws Exception { .anyMatch(line -> line.matches("(.*)Removed path: (.*)")))); } + @Test + public void testValidateWithSshKey(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + LOG.info("Running testValidateWithSshKey"); + + // Setup + WireMock wireMock = wmRuntimeInfo.getWireMock(); + wireMock.register(WireMock.get(WireMock.urlEqualTo("/api/user")) + .willReturn( + WireMock.jsonResponse(new GitHubServerContainer.UserApiResponse("fake-owner", "User"), 200))); + + Invoker invoker = buildInvoker(); + InvocationRequest request = + buildRequest("validate --maven-home %s --ssh-private-key %s --debug --github-api-url %s/api" + .formatted( + getModernizerMavenHome(), + generatePrivateKey("testValidateWithSshKey"), + wmRuntimeInfo.getHttpBaseUrl())); + InvocationResult result = invoker.execute(request); + assertAll( + () -> assertEquals(0, result.getExitCode()), + () -> assertTrue(Files.readAllLines(outputPath.resolve("stdout.txt")).stream() + .anyMatch(line -> line.matches("(.*)GitHub owner: fake-owner(.*)"))), + () -> assertTrue(Files.readAllLines(outputPath.resolve("stdout.txt")).stream() + .anyMatch(line -> line.matches("(.*)Validation successful(.*)")))); + } + @Test public void testValidate(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { LOG.info("Running testValidate"); @@ -107,11 +159,12 @@ public void testValidate(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { // Setup WireMock wireMock = wmRuntimeInfo.getWireMock(); wireMock.register(WireMock.get(WireMock.urlEqualTo("/api/user")) - .willReturn(WireMock.jsonResponse(USER_API_RESPONSE, 200))); + .willReturn( + WireMock.jsonResponse(new GitHubServerContainer.UserApiResponse("fake-owner", "User"), 200))); Invoker invoker = buildInvoker(); InvocationRequest request = - buildRequest("validate --debug --github-api-url " + wmRuntimeInfo.getHttpBaseUrl() + "/api"); + buildRequest("validate --debug --github-api-url %s/api".formatted(wmRuntimeInfo.getHttpBaseUrl())); InvocationResult result = invoker.execute(request); assertAll( () -> assertEquals(0, result.getExitCode()), @@ -134,60 +187,41 @@ public void testListRecipes() throws Exception { line -> line.matches(".*FetchMetadata - Extracts metadata from a Jenkins plugin.*")))); } - @Test - public void testBuildMetadata(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { - LOG.info("Running testBuildMetadataForDeprecatedPlugin"); - - PluginStatsApiResponse pluginStatsApiResponse = new PluginStatsApiResponse(Map.of("a-fake-plugin", 1)); - UpdateCenterApiResponse updateCenterApiResponse = new UpdateCenterApiResponse( - Map.of( - "a-fake-plugin", - new UpdateCenterData.UpdateCenterPlugin( - "a-fake-plugin", - "1", - gitRemote.getGitRepoURIAsHttp().toString(), - "main", - "io.jenkins.plugins:a-fake", - null)), - Map.of( - "a-fake-plugin", - new UpdateCenterData.DeprecatedPlugin("https://github.com/jenkinsci/a-fake-plugin"))); - PluginVersionsApiResponse pluginVersionsApiResponse = new PluginVersionsApiResponse( - Map.of("a-fake-plugin", Map.of("1", new PluginVersionData.PluginVersionPlugin("a-fake-plugin", "1")))); - HealthScoreApiResponse pluginHealthScoreApiResponse = - new HealthScoreApiResponse(Map.of("a-fake-plugin", new HealthScoreData.HealthScorePlugin(100d))); + @ParameterizedTest + @MethodSource("testsPlugins") + public void testBuildMetadata(PluginMetadata expectedMetadata, WireMockRuntimeInfo wmRuntimeInfo) throws Exception { - // Setup - WireMock wireMock = wmRuntimeInfo.getWireMock(); - wireMock.register(WireMock.get(WireMock.urlEqualTo("/api/user")) - .willReturn(WireMock.jsonResponse(USER_API_RESPONSE, 200))); - wireMock.register(WireMock.get(WireMock.urlEqualTo("/api/repos/jenkinsci/testRepo")) - .willReturn(WireMock.jsonResponse( - new RepoApiResponse(gitRemote.getGitRepoURIAsHttp().toString()), 200))); - wireMock.register(WireMock.get(WireMock.urlEqualTo("/update-center.json")) - .willReturn(WireMock.jsonResponse(updateCenterApiResponse, 200))); - wireMock.register(WireMock.get(WireMock.urlEqualTo("/plugin-versions.json")) - .willReturn(WireMock.jsonResponse(pluginVersionsApiResponse, 200))); - wireMock.register(WireMock.get(WireMock.urlEqualTo("/scores")) - .willReturn(WireMock.jsonResponse(pluginHealthScoreApiResponse, 200))); - wireMock.register(WireMock.get(WireMock.urlEqualTo("/jenkins-stats/svg/202406-plugins.csv")) - .willReturn(WireMock.jsonResponse(pluginStatsApiResponse, 200))); + String plugin = expectedMetadata.getPluginName(); - Invoker invoker = buildInvoker(); - InvocationRequest request = buildRequest( - "build-metadata --plugins a-fake-plugin --debug --cache-path %s --github-api-url %s --jenkins-update-center %s --jenkins-plugin-info %s --plugin-health-score %s --jenkins-plugins-stats-installations-url %s" - .formatted( - cachePath, - wmRuntimeInfo.getHttpBaseUrl() + "/api", - wmRuntimeInfo.getHttpBaseUrl() + "/update-center.json", - wmRuntimeInfo.getHttpBaseUrl() + "/plugin-versions.json", - wmRuntimeInfo.getHttpBaseUrl() + "/scores", - wmRuntimeInfo.getHttpBaseUrl() + "/jenkins-stats/svg/202406-plugins.csv")); - InvocationResult result = invoker.execute(request); - assertAll( - () -> assertEquals(0, result.getExitCode()), - () -> assertTrue(Files.readAllLines(outputPath.resolve("stdout.txt")).stream() - .anyMatch(line -> line.matches(".*Error: Plugin is deprecated.*")))); + // Junit attachment with logs file for the plugin build + System.out.printf( + "[[ATTACHMENT|%s]]%n", Plugin.build(plugin).getLogFile().toAbsolutePath()); + + try (GitHubServerContainer gitRemote = new GitHubServerContainer(wmRuntimeInfo, keysPath, plugin, "main")) { + + gitRemote.start(); + + Invoker invoker = buildInvoker(); + InvocationRequest request = buildRequest("build-metadata %s".formatted(getRunArgs(wmRuntimeInfo, plugin))); + InvocationResult result = invoker.execute(request); + + // Assert output + assertAll( + () -> assertEquals(0, result.getExitCode()), + () -> assertTrue(Files.readAllLines(outputPath.resolve("stdout.txt")).stream() + .anyMatch(line -> + line.matches(".*Metadata was fetched for plugin empty and is available at.*")))); + + // Assert some metadata + PluginMetadata metadata = JsonUtils.fromJson( + cachePath + .resolve("jenkins-plugin-modernizer-cli") + .resolve(plugin) + .resolve(CacheManager.PLUGIN_METADATA_CACHE_KEY), + PluginMetadata.class); + + assertEquals(expectedMetadata.getJenkinsVersion(), metadata.getJenkinsVersion()); + } } /** @@ -262,21 +296,59 @@ private InvocationRequest buildRequest(String args) { } /** - * Login API response + * Generate a Ed25519 private key and save it + * @param name The name of the key + * @throws Exception If an error occurs */ - private record UserApiResponse(String login, String type) {} - - private record RepoApiResponse(String clone_url) {} - - private static final UserApiResponse USER_API_RESPONSE = new UserApiResponse("fake-owner", "User"); - - private record PluginStatsApiResponse(Map plugins) {} - - private record UpdateCenterApiResponse( - Map plugins, - Map deprecations) {} + private Path generatePrivateKey(String name) throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519", "BC"); + KeyPair pair = keyPairGenerator.generateKeyPair(); + PrivateKey privateKey = pair.getPrivate(); + File privateKeyFile = keysPath.resolve(name).toFile(); + try (FileWriter fileWriter = new FileWriter(privateKeyFile); + JcaPEMWriter pemWriter = new JcaPEMWriter(fileWriter)) { + pemWriter.writeObject(privateKey); + } + return privateKeyFile.toPath(); + } - private record PluginVersionsApiResponse(Map> plugins) {} + /** + * Get the modernizer maven home + * @return Use version from the target directory + */ + private Path getModernizerMavenHome() { + return Path.of("target/apache-maven-3.9.9").toAbsolutePath(); + } - private record HealthScoreApiResponse(Map plugins) {} + /** + * Get the URL arguments + * @param wmRuntimeInfo The WireMock runtime info + * @param plugin The plugin + * @return the URL arguments + */ + private String getRunArgs(WireMockRuntimeInfo wmRuntimeInfo, String plugin) { + return """ + --plugins %s + --debug + --maven-home %s + --ssh-private-key %s + --cache-path %s + --github-api-url %s + --jenkins-update-center %s + --jenkins-plugin-info %s + --plugin-health-score %s + --jenkins-plugins-stats-installations-url %s + """ + .formatted( + plugin, + getModernizerMavenHome(), + keysPath.resolve(plugin), + cachePath, + wmRuntimeInfo.getHttpBaseUrl() + "/api", + wmRuntimeInfo.getHttpBaseUrl() + "/update-center.json", + wmRuntimeInfo.getHttpBaseUrl() + "/plugin-versions.json", + wmRuntimeInfo.getHttpBaseUrl() + "/scores", + wmRuntimeInfo.getHttpBaseUrl() + "/jenkins-stats/svg/202406-plugins.csv") + .replaceAll("\\s+", " "); + } } diff --git a/plugin-modernizer-cli/src/test/java/io/jenkins/tools/pluginmodernizer/cli/utils/GitHubServerContainer.java b/plugin-modernizer-cli/src/test/java/io/jenkins/tools/pluginmodernizer/cli/utils/GitHubServerContainer.java new file mode 100644 index 00000000..b32aff6a --- /dev/null +++ b/plugin-modernizer-cli/src/test/java/io/jenkins/tools/pluginmodernizer/cli/utils/GitHubServerContainer.java @@ -0,0 +1,221 @@ +package io.jenkins.tools.pluginmodernizer.cli.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.github.sparsick.testcontainers.gitserver.GitServerVersions; +import com.github.sparsick.testcontainers.gitserver.plain.GitServerContainer; +import com.github.sparsick.testcontainers.gitserver.plain.SshIdentity; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import io.jenkins.tools.pluginmodernizer.core.model.HealthScoreData; +import io.jenkins.tools.pluginmodernizer.core.model.ModernizerException; +import io.jenkins.tools.pluginmodernizer.core.model.PluginVersionData; +import io.jenkins.tools.pluginmodernizer.core.model.UpdateCenterData; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.ExecConfig; +import org.testcontainers.utility.MountableFile; + +/** + * The GitHub server container accessible by SSH + */ +public class GitHubServerContainer extends GitServerContainer { + + /** + * The logger + */ + private static final Logger LOG = LoggerFactory.getLogger(GitHubServerContainer.class); + + /** + * The plugin name + */ + private final String plugin; + + /** + * The WireMock runtime info + */ + private final WireMockRuntimeInfo wmRuntimeInfo; + + /** + * The branch + */ + private final String branch; + + /** + * The path containers the SSH + */ + private final Path keysPath; + + /** + * Create a GitHub server container + */ + public GitHubServerContainer(WireMockRuntimeInfo wmRuntimeInfo, Path keysPath, String plugin, String branch) { + super(GitServerVersions.V2_45.getDockerImageName()); + this.plugin = plugin; + this.wmRuntimeInfo = wmRuntimeInfo; + this.branch = branch; + this.keysPath = keysPath; + withSshKeyAuth(); + withGitRepo(plugin); + } + + @Override + public void start() { + super.start(); + setupGitContainer(); + setupMock(); + } + + /** + * Setup mocks for integration tests with WireMock and Testcontainer git + */ + private void setupMock() { + + // Setup responses + PluginStatsApiResponse pluginStatsApiResponse = new PluginStatsApiResponse(Map.of(plugin, 1)); + UpdateCenterApiResponse updateCenterApiResponse = new UpdateCenterApiResponse( + Map.of( + plugin, + new UpdateCenterData.UpdateCenterPlugin( + plugin, + "1", + this.getGitRepoURIAsSSH().toString(), + branch, + "io.jenkins.plugins:%s".formatted(plugin), + null)), + Map.of()); + PluginVersionsApiResponse pluginVersionsApiResponse = new PluginVersionsApiResponse( + Map.of(plugin, Map.of("1", new PluginVersionData.PluginVersionPlugin(plugin, "1")))); + HealthScoreApiResponse pluginHealthScoreApiResponse = + new HealthScoreApiResponse(Map.of(plugin, new HealthScoreData.HealthScorePlugin(100d))); + + // Setup mocks + WireMock wireMock = wmRuntimeInfo.getWireMock(); + wireMock.register(WireMock.get(WireMock.urlEqualTo("/api/user")) + .willReturn(WireMock.jsonResponse(new UserApiResponse("fake-owner", "User"), 200))); + wireMock.register(WireMock.get(WireMock.urlEqualTo("/api/repos/jenkinsci/%s".formatted(plugin))) + .willReturn(WireMock.jsonResponse( + new RepoApiResponse( + "main", + "%s/%s/%s".formatted(wmRuntimeInfo.getHttpBaseUrl(), "fake-owner", plugin), + this.getGitRepoURIAsSSH().toString()), + 200))); + wireMock.register(WireMock.get(WireMock.urlEqualTo("/update-center.json")) + .willReturn(WireMock.jsonResponse(updateCenterApiResponse, 200))); + wireMock.register(WireMock.get(WireMock.urlEqualTo("/plugin-versions.json")) + .willReturn(WireMock.jsonResponse(pluginVersionsApiResponse, 200))); + wireMock.register(WireMock.get(WireMock.urlEqualTo("/scores")) + .willReturn(WireMock.jsonResponse(pluginHealthScoreApiResponse, 200))); + wireMock.register(WireMock.get(WireMock.urlEqualTo("/jenkins-stats/svg/202406-plugins.csv")) + .willReturn(WireMock.jsonResponse(pluginStatsApiResponse, 200))); + + // Setup SSH key to access the container + SshIdentity sshIdentity = this.getSshClientIdentity(); + byte[] privateKey = sshIdentity.getPrivateKey(); + try { + Files.write(keysPath.resolve(plugin), privateKey); + LOG.info("Private key: {}", keysPath.resolve(plugin)); + } catch (IOException e) { + throw new ModernizerException("Error writing private key", e); + } + } + + /** + * Run a command on the git container + * @param command The command + */ + private void runContainerGitCommand(String user, String command) { + try { + Container.ExecResult containerResult = this.execInContainer(ExecConfig.builder() + .user(user) + .command(command.split(" ")) // We assume no command contains space + .build()); + LOG.debug("Running command on container: {}", command); + LOG.debug("Stdout: {}", containerResult.getStdout()); + LOG.debug("Stderr: {}", containerResult.getStderr()); + + assertEquals( + 0, + containerResult.getExitCode(), + "Command '%s' failed with status code '%s' and output '%s' and error '%s'" + .formatted( + command, + containerResult.getExitCode(), + containerResult.getStdout(), + containerResult.getStderr())); + } catch (IOException | InterruptedException e) { + throw new ModernizerException("Error running command: %s".formatted(command), e); + } + } + + /** + * Setup the git container + */ + private void setupGitContainer() { + String gitRepoPath = String.format("/srv/git/%s.git", plugin); + String sourceDirectory = "src/test/resources/%s".formatted(plugin); + assertTrue( + Files.exists(Path.of(sourceDirectory)), + "Source directory %s does not exist".formatted(sourceDirectory)); + runContainerGitCommand("root", "rm -Rf %s".formatted(gitRepoPath)); + LOG.debug("Copying %s to %s".formatted(sourceDirectory, gitRepoPath)); + this.copyFileToContainer(MountableFile.forHostPath(sourceDirectory.formatted(plugin)), gitRepoPath); + LOG.debug("Copied %s to %s".formatted(sourceDirectory, gitRepoPath)); + runContainerGitCommand("root", "chown -R git:git %s".formatted(gitRepoPath)); + runContainerGitCommand("git", "ls -la %s".formatted(gitRepoPath)); + runContainerGitCommand("git", "git config --global init.defaultBranch main"); + runContainerGitCommand("git", "git config --global --add safe.directory %s".formatted(gitRepoPath)); + runContainerGitCommand("git", "git config --global user.name Fake"); + runContainerGitCommand("git", "git config --global user.email fake-email@example.com"); + runContainerGitCommand("git", "git init %s".formatted(gitRepoPath)); + runContainerGitCommand("git", "git -C %s status".formatted(gitRepoPath)); + runContainerGitCommand("git", "git -C %s add .".formatted(gitRepoPath)); + runContainerGitCommand("git", "git -C %s status".formatted(gitRepoPath)); + runContainerGitCommand("git", "git -C %s commit -m init".formatted(gitRepoPath)); + runContainerGitCommand("git", "git -C %s status".formatted(gitRepoPath)); + } + + /** + * Login API response + */ + public record UserApiResponse(String login, String type) {} + + /** + * Setup the mock + * @param ssh_url the SSH URL + */ + public record RepoApiResponse(String default_branch, String clone_url, String ssh_url) {} + + /** + * Setup the mock + * @param plugins + */ + public record PluginStatsApiResponse(Map plugins) {} + + /** + * Update center API response + * @param plugins the plugins + * @param deprecations the deprecations + */ + public record UpdateCenterApiResponse( + Map plugins, + Map deprecations) {} + + /** + * Plugin versions API response + * @param plugins the plugins + */ + public record PluginVersionsApiResponse(Map> plugins) {} + + /** + * Health score API response + * @param plugins the plugins + */ + public record HealthScoreApiResponse(Map plugins) {} +} diff --git a/plugin-modernizer-cli/src/test/resources/a-fake-plugin/pom.xml b/plugin-modernizer-cli/src/test/resources/a-fake-plugin/pom.xml deleted file mode 100644 index e69de29b..00000000 diff --git a/plugin-modernizer-core/pom.xml b/plugin-modernizer-core/pom.xml index a32ac174..c34e4136 100644 --- a/plugin-modernizer-core/pom.xml +++ b/plugin-modernizer-core/pom.xml @@ -55,7 +55,14 @@ org.apache.maven.shared maven-invoker - 3.3.0 + + + org.apache.sshd + sshd-core + + + org.apache.sshd + sshd-git org.bouncycastle @@ -69,6 +76,10 @@ org.eclipse.jgit org.eclipse.jgit + + org.eclipse.jgit + org.eclipse.jgit.ssh.apache + org.kohsuke github-api diff --git a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/config/Config.java b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/config/Config.java index b33d69ae..8bf14b7a 100644 --- a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/config/Config.java +++ b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/config/Config.java @@ -22,6 +22,7 @@ public class Config { private final URL githubApiUrl; private final Path cachePath; private final Path mavenHome; + private final Path mavenLocalRepo; private final boolean dryRun; private final boolean draft; private final boolean removeForks; @@ -29,6 +30,7 @@ public class Config { private final Long githubAppId; private final Long githubAppSourceInstallationId; private final Long githubAppTargetInstallationId; + private final Path sshPrivateKey; private Config( String version, @@ -36,6 +38,7 @@ private Config( Long githubAppId, Long githubAppSourceInstallationId, Long githubAppTargetInstallationId, + Path sshPrivateKey, List plugins, Recipe recipe, URL jenkinsUpdateCenter, @@ -45,6 +48,7 @@ private Config( URL githubApiUrl, Path cachePath, Path mavenHome, + Path mavenLocalRepo, boolean dryRun, boolean draft, boolean removeForks) { @@ -53,6 +57,7 @@ private Config( this.githubAppId = githubAppId; this.githubAppSourceInstallationId = githubAppSourceInstallationId; this.githubAppTargetInstallationId = githubAppTargetInstallationId; + this.sshPrivateKey = sshPrivateKey; this.plugins = plugins; this.recipe = recipe; this.jenkinsUpdateCenter = jenkinsUpdateCenter; @@ -62,6 +67,7 @@ private Config( this.githubApiUrl = githubApiUrl; this.cachePath = cachePath; this.mavenHome = mavenHome; + this.mavenLocalRepo = mavenLocalRepo; this.dryRun = dryRun; this.draft = draft; this.removeForks = removeForks; @@ -87,6 +93,10 @@ public Long getGithubAppTargetInstallationId() { return githubAppTargetInstallationId; } + public Path getSshPrivateKey() { + return sshPrivateKey; + } + public List getPlugins() { return plugins; } @@ -124,11 +134,21 @@ public URL getGithubApiUrl() { } public Path getCachePath() { - return cachePath; + return cachePath.toAbsolutePath(); } public Path getMavenHome() { - return mavenHome; + if (mavenHome == null) { + return null; + } + return mavenHome.toAbsolutePath(); + } + + public Path getMavenLocalRepo() { + if (mavenLocalRepo == null) { + return Settings.DEFAULT_MAVEN_LOCAL_REPO; + } + return mavenLocalRepo.toAbsolutePath(); } public boolean isDryRun() { @@ -157,6 +177,7 @@ public static class Builder { private Long githubAppId; private Long githubAppSourceInstallationId; private Long githubAppTargetInstallationId; + private Path sshPrivateKey = Settings.SSH_PRIVATE_KEY; private List plugins; private Recipe recipe; private URL jenkinsUpdateCenter = Settings.DEFAULT_UPDATE_CENTER_URL; @@ -166,6 +187,7 @@ public static class Builder { private URL githubApiUrl = Settings.GITHUB_API_URL; private Path cachePath = Settings.DEFAULT_CACHE_PATH; private Path mavenHome = Settings.DEFAULT_MAVEN_HOME; + private Path mavenLocalRepo = Settings.DEFAULT_MAVEN_LOCAL_REPO; private boolean dryRun = false; private boolean draft = false; public boolean removeForks = false; @@ -195,6 +217,11 @@ public Builder withGitHubAppTargetInstallationId(Long githubAppInstallationId) { return this; } + public Builder withSshPrivateKey(Path sshPrivateKey) { + this.sshPrivateKey = sshPrivateKey; + return this; + } + public Builder withPlugins(List plugins) { this.plugins = plugins; return this; @@ -254,6 +281,13 @@ public Builder withMavenHome(Path mavenHome) { return this; } + public Builder withMavenLocalRepo(Path mavenLocalRepo) { + if (mavenLocalRepo != null) { + this.mavenLocalRepo = mavenLocalRepo; + } + return this; + } + public Builder withDryRun(boolean dryRun) { this.dryRun = dryRun; return this; @@ -276,6 +310,7 @@ public Config build() { githubAppId, githubAppSourceInstallationId, githubAppTargetInstallationId, + sshPrivateKey, plugins, recipe, jenkinsUpdateCenter, @@ -285,6 +320,7 @@ public Config build() { githubApiUrl, cachePath, mavenHome, + mavenLocalRepo, dryRun, draft, removeForks); diff --git a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/config/Settings.java b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/config/Settings.java index 873f5d43..40ed4aca 100644 --- a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/config/Settings.java +++ b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/config/Settings.java @@ -41,10 +41,14 @@ public class Settings { public static final Path DEFAULT_MAVEN_HOME; + public static final Path DEFAULT_MAVEN_LOCAL_REPO; + public static final String MAVEN_REWRITE_PLUGIN_VERSION; public static final String GITHUB_TOKEN; + public static final Path SSH_PRIVATE_KEY; + public static final String GITHUB_OWNER; public static final Path GITHUB_APP_PRIVATE_KEY_FILE; @@ -75,19 +79,27 @@ public class Settings { private Settings() {} static { - String cacheBaseDir = System.getProperty("user.home"); - if (cacheBaseDir == null) { - cacheBaseDir = System.getProperty("user.dir"); + String userBaseDir = System.getProperty("user.home"); + if (userBaseDir == null) { + userBaseDir = System.getProperty("user.dir"); } String cacheDirFromEnv = System.getenv("CACHE_DIR"); if (cacheDirFromEnv == null) { - DEFAULT_CACHE_PATH = Paths.get(cacheBaseDir, ".cache", CACHE_SUBDIR); + DEFAULT_CACHE_PATH = Paths.get(userBaseDir, ".cache", CACHE_SUBDIR); } else { DEFAULT_CACHE_PATH = Paths.get(cacheDirFromEnv, CACHE_SUBDIR); } DEFAULT_MAVEN_HOME = getDefaultMavenHome(); + DEFAULT_MAVEN_LOCAL_REPO = getDefaultMavenLocalRepo(); MAVEN_REWRITE_PLUGIN_VERSION = getRewritePluginVersion(); + String sshPrivateKey = System.getenv("SSH_PRIVATE_KEY"); + if (sshPrivateKey != null) { + SSH_PRIVATE_KEY = Paths.get(sshPrivateKey); + } else { + SSH_PRIVATE_KEY = Paths.get(userBaseDir, ".ssh", "id_rsa"); + } + GITHUB_TOKEN = getGithubToken(); GITHUB_OWNER = getGithubOwner(); GITHUB_APP_PRIVATE_KEY_FILE = getGithubAppPrivateKeyFile(); @@ -157,6 +169,18 @@ private static Path getDefaultMavenHome() { return Path.of(mavenHome); } + private static Path getDefaultMavenLocalRepo() { + String mavenLocalRepo = System.getenv("MAVEN_LOCAL_REPO"); + if (mavenLocalRepo == null) { + String userBaseDir = System.getProperty("user.home"); + if (userBaseDir == null) { + userBaseDir = System.getProperty("user.dir"); + } + return Path.of(userBaseDir, ".m2", "repository").toAbsolutePath(); + } + return Path.of(mavenLocalRepo); + } + private static @Nullable String getRewritePluginVersion() { return readProperty("openrewrite.maven.plugin.version", "versions.properties"); } @@ -259,7 +283,7 @@ public static Path getDefaultSdkManJava(final String key) { } public static Path getPluginsDirectory(Plugin plugin) { - return DEFAULT_CACHE_PATH.resolve(plugin.getName()); + return plugin.getConfig().getCachePath().resolve(plugin.getName()); } /** diff --git a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/github/GHService.java b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/github/GHService.java index b12b8896..84be55c3 100644 --- a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/github/GHService.java +++ b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/github/GHService.java @@ -12,19 +12,22 @@ import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.stream.StreamSupport; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.common.keyprovider.FileKeyPairProvider; +import org.apache.sshd.git.transport.GitSshdSessionFactory; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.RefAlreadyExistsException; +import org.eclipse.jgit.errors.UnsupportedCredentialItem; import org.eclipse.jgit.lib.Ref; -import org.eclipse.jgit.transport.PushResult; -import org.eclipse.jgit.transport.RefSpec; -import org.eclipse.jgit.transport.URIish; -import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.eclipse.jgit.transport.*; import org.kohsuke.github.GHApp; import org.kohsuke.github.GHAppInstallationToken; import org.kohsuke.github.GHBranchSync; @@ -61,6 +64,11 @@ public class GHService { */ private GHApp app; + /** + * If the authentication is done using SSH key + */ + private boolean sshKeyAuth = false; + /** * Validate the configuration of the GHService */ @@ -68,6 +76,7 @@ public void validate() { if (config.isFetchMetadataOnly()) { return; } + setSshKeyAuth(); if (Settings.GITHUB_TOKEN == null && (config.getGithubAppId() == null || config.getGithubAppSourceInstallationId() == null @@ -149,6 +158,20 @@ public void connect() { } catch (IOException e) { throw new ModernizerException("Failed to connect to GitHub. Cannot use GitHub/SCM integration", e); } + // Ensure to set up SSH client for Git operations + setSshKeyAuth(); + if (sshKeyAuth) { + try { + SshClient client = SshClient.setUpDefaultClient(); + FileKeyPairProvider keyPairProvider = + new FileKeyPairProvider(Collections.singletonList(config.getSshPrivateKey())); + client.setKeyIdentityProvider(keyPairProvider); + GitSshdSessionFactory sshdFactory = new GitSshdSessionFactory(client); + SshSessionFactory.setInstance(sshdFactory); + } catch (Exception e) { + throw new ModernizerException("Failed to set up SSH client for Git operations", e); + } + } } /** @@ -488,8 +511,8 @@ public void fetch(Plugin plugin) { } try { fetchRepository(plugin); - LOG.debug("Fetched repository from {}", repository.getHtmlUrl()); - } catch (GitAPIException e) { + LOG.debug("Fetched repository from {}", repository.getSshUrl()); + } catch (GitAPIException | URISyntaxException e) { LOG.error("Failed to fetch the repository", e); plugin.addError("Failed to fetch the repository", e); plugin.raiseLastError(); @@ -501,12 +524,27 @@ public void fetch(Plugin plugin) { * @param plugin The plugin to fetch * @throws GitAPIException If the fetch operation failed */ - private void fetchRepository(Plugin plugin) throws GitAPIException { + private void fetchRepository(Plugin plugin) throws GitAPIException, URISyntaxException { LOG.debug("Fetching {}", plugin.getName()); GHRepository repository = config.isDryRun() || config.isFetchMetadataOnly() || plugin.isArchived(this) ? getRepository(plugin) : getRepositoryFork(plugin); - String remoteUrl = repository.getHttpTransportUrl(); + + // Get the correct URI + URIish remoteUri = + sshKeyAuth ? new URIish(repository.getSshUrl()) : new URIish(repository.getHttpTransportUrl()); + + // Ensure to set port 22 if not set on remote URL to work with apache mina sshd + if (sshKeyAuth) { + if (remoteUri.getScheme() == null) { + remoteUri = remoteUri.setScheme("ssh"); + LOG.debug("Setting scheme ssh for remote URI {}", remoteUri); + } + if (remoteUri.getPort() == -1) { + remoteUri = remoteUri.setPort(22); + LOG.debug("Setting port 22 for remote URI {}", remoteUri); + } + } // Fetch latest changes if (Files.isDirectory(plugin.getLocalRepository())) { // Ensure to set the correct remote, reset changes and pull @@ -516,10 +554,13 @@ private void fetchRepository(Plugin plugin) throws GitAPIException { : plugin.getRemoteForkRepository(this).getDefaultBranch(); git.remoteSetUrl() .setRemoteName("origin") - .setRemoteUri(new URIish(repository.getHttpTransportUrl())) + .setRemoteUri(remoteUri) .call(); - git.fetch().setRemote("origin").call(); - LOG.debug("Resetting changes and pulling latest changes from {}", remoteUrl); + git.fetch() + .setCredentialsProvider(getCredentialProvider()) + .setRemote("origin") + .call(); + LOG.debug("Resetting changes and pulling latest changes from {}", remoteUri); git.reset() .setMode(ResetCommand.ResetType.HARD) .setRef("origin/" + defaultBranch) @@ -530,11 +571,12 @@ private void fetchRepository(Plugin plugin) throws GitAPIException { .setName(defaultBranch) .call(); git.pull() + .setCredentialsProvider(getCredentialProvider()) .setRemote("origin") .setRemoteBranchName(defaultBranch) .call(); - LOG.info("Fetched repository from {} to branch {}", remoteUrl, ref.getName()); - } catch (IOException | URISyntaxException e) { + LOG.info("Fetched repository from {} to branch {}", remoteUri, ref.getName()); + } catch (IOException e) { plugin.addError("Failed fetch repository", e); plugin.raiseLastError(); } @@ -542,10 +584,12 @@ private void fetchRepository(Plugin plugin) throws GitAPIException { // Clone the repository else { try (Git git = Git.cloneRepository() - .setURI(remoteUrl) + .setCredentialsProvider(getCredentialProvider()) + .setRemote("origin") + .setURI(remoteUri.toString()) .setDirectory(plugin.getLocalRepository().toFile()) .call()) { - LOG.debug("Clone successfully from {}", remoteUrl); + LOG.debug("Clone successfully from {}", remoteUri); } } } @@ -698,9 +742,8 @@ public void pushChanges(Plugin plugin) { List results = StreamSupport.stream( git.push() .setForce(true) - .setCredentialsProvider( - new UsernamePasswordCredentialsProvider(Settings.GITHUB_TOKEN, "")) .setRemote("origin") + .setCredentialsProvider(getCredentialProvider()) .setRefSpecs(new RefSpec(BRANCH_NAME + ":" + BRANCH_NAME)) .call() .spliterator(), @@ -789,6 +832,16 @@ public void openPullRequest(Plugin plugin) { } } + /** + * Get the current credentials provider + * @return The credentials provider + */ + private CredentialsProvider getCredentialProvider() { + return sshKeyAuth + ? new SshCredentialsProvider() + : new UsernamePasswordCredentialsProvider(Settings.GITHUB_TOKEN, ""); + } + /** * Return if the given repository has any pull request originating from it * Typically to avoid deleting fork with open pull requests @@ -852,6 +905,14 @@ public String getGithubOwner() { : getCurrentUser().getLogin(); } + /** + * Return if SSH auth is used + * @return True if SSH key is used + */ + public boolean isSshKeyAuth() { + return sshKeyAuth; + } + /** * Ensure the forked reository correspond of the origin parent repository * @param originalRepo The original repository @@ -874,4 +935,39 @@ private void checkSameParentRepository(Plugin plugin, GHRepository originalRepo, plugin); } } + + /** + * Set the SSH key authentication if needed + */ + private void setSshKeyAuth() { + Path privateKey = config.getSshPrivateKey(); + if (Files.isRegularFile(privateKey)) { + sshKeyAuth = true; + LOG.debug("Using SSH private key for git operation: {}", privateKey); + } else { + sshKeyAuth = false; + LOG.debug("SSH private key file {} does not exist. Will use GH_TOKEN for git operation", privateKey); + } + } + + /** + * JGit expect a credential provider even if transport and authentication is none at transport level with + * Apache Mina SSHD. This is therefor a dummy provider + */ + private static class SshCredentialsProvider extends CredentialsProvider { + @Override + public boolean isInteractive() { + return false; + } + + @Override + public boolean supports(CredentialItem... credentialItems) { + return false; + } + + @Override + public boolean get(URIish uri, CredentialItem... credentialItems) throws UnsupportedCredentialItem { + return false; + } + } } diff --git a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/impl/MavenInvoker.java b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/impl/MavenInvoker.java index 49da86aa..507ac70d 100644 --- a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/impl/MavenInvoker.java +++ b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/impl/MavenInvoker.java @@ -114,6 +114,7 @@ public void invokeRewrite(Plugin plugin) { private String[] getSingleRecipeArgs(Recipe recipe) { List goals = new ArrayList<>(); goals.add("org.openrewrite.maven:rewrite-maven-plugin:" + Settings.MAVEN_REWRITE_PLUGIN_VERSION + ":run"); + goals.add("-Dmaven.repo.local=%s".formatted(config.getMavenLocalRepo())); goals.add("-Drewrite.activeRecipes=" + recipe.getName()); goals.add("-Drewrite.recipeArtifactCoordinates=io.jenkins.plugin-modernizer:plugin-modernizer-core:" + config.getVersion()); @@ -163,10 +164,10 @@ private void validatePom(Plugin plugin) { } /** - * Validate the Maven home directory. + * Validate the Maven home and local repo directory. * @throws IllegalArgumentException if the Maven home directory is not set or invalid. */ - public void validateMavenHome() { + public void validateMaven() { Path mavenHome = config.getMavenHome(); if (mavenHome == null) { throw new ModernizerException( @@ -176,6 +177,14 @@ public void validateMavenHome() { if (!Files.isDirectory(mavenHome) || !Files.isExecutable(mavenHome.resolve("bin/mvn"))) { throw new ModernizerException("Invalid Maven home directory at '%s'.".formatted(mavenHome)); } + + Path mavenLocalRepo = config.getMavenLocalRepo(); + if (mavenLocalRepo == null) { + throw new ModernizerException("Maven local repository is not set."); + } + if (!Files.isDirectory(mavenLocalRepo)) { + throw new ModernizerException("Invalid Maven local repository at '%s'.".formatted(mavenLocalRepo)); + } } /** diff --git a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/impl/PluginModernizer.java b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/impl/PluginModernizer.java index 3cb094c2..397447f2 100644 --- a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/impl/PluginModernizer.java +++ b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/impl/PluginModernizer.java @@ -38,7 +38,7 @@ public class PluginModernizer { * Validate the configuration */ public void validate() { - mavenInvoker.validateMavenHome(); + mavenInvoker.validateMaven(); mavenInvoker.validateMavenVersion(); if (!ghService.isConnected()) { ghService.connect(); @@ -72,6 +72,14 @@ public String getGithubOwner() { return ghService.getGithubOwner(); } + /** + * Expose the effective SSH private key path + * @return The SSH private key path + */ + public String getSshPrivateKeyPath() { + return config.getSshPrivateKey().toString(); + } + /** * Expose the effective Maven version * @return The Maven version @@ -90,6 +98,14 @@ public String getMavenHome() { return config.getMavenHome().toString(); } + /** + * Expose the effective Maven local repository + * @return The Maven local repository + */ + public String getMavenLocalRepo() { + return config.getMavenLocalRepo().toString(); + } + /** * Expose the effective cache path * @return The cache path @@ -125,11 +141,18 @@ public void start() { LOG.debug("Plugins: {}", config.getPlugins()); LOG.debug("Recipe: {}", config.getRecipe().getName()); LOG.debug("GitHub owner: {}", config.getGithubOwner()); + if (ghService.isSshKeyAuth()) { + LOG.debug("SSH private key: {}", config.getSshPrivateKey()); + } else { + LOG.debug("Using GitHub token for git authentication"); + } LOG.debug("Update Center Url: {}", config.getJenkinsUpdateCenter()); LOG.debug("Plugin versions Url: {}", config.getJenkinsPluginVersions()); LOG.debug("Plugin Health Score Url: {}", config.getPluginHealthScore()); LOG.debug("Installation Stats Url: {}", config.getPluginStatsInstallations()); LOG.debug("Cache Path: {}", config.getCachePath()); + LOG.debug("Maven Home: {}", config.getMavenHome()); + LOG.debug("Maven Local Repository: {}", config.getMavenLocalRepo()); LOG.debug("Dry Run: {}", config.isDryRun()); LOG.debug("Maven rewrite plugin version: {}", Settings.MAVEN_REWRITE_PLUGIN_VERSION); @@ -374,6 +397,7 @@ private void printResults(List plugins) { LOG.error("Stacktrace: ", error); } } + } // Display what's done else { diff --git a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/utils/PluginService.java b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/utils/PluginService.java index 2e609df8..303b3ceb 100644 --- a/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/utils/PluginService.java +++ b/plugin-modernizer-core/src/main/java/io/jenkins/tools/pluginmodernizer/core/utils/PluginService.java @@ -38,7 +38,7 @@ public String extractRepoName(Plugin plugin) { String scmUrl = updateCenterPlugin.scm(); int lastSlashIndex = scmUrl.lastIndexOf('/'); if (lastSlashIndex != -1 && lastSlashIndex < scmUrl.length() - 1) { - return scmUrl.substring(lastSlashIndex + 1); + return scmUrl.substring(lastSlashIndex + 1).replaceAll(".git$", ""); } else { plugin.addError("Invalid SCM URL format"); plugin.raiseLastError(); diff --git a/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/config/ConfigTest.java b/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/config/ConfigTest.java index 790367fe..d7f644bb 100644 --- a/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/config/ConfigTest.java +++ b/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/config/ConfigTest.java @@ -27,8 +27,8 @@ public void testConfigBuilderWithAllFields() throws MalformedURLException { Recipe recipe = Mockito.mock(Recipe.class); Mockito.doReturn("recipe1").when(recipe).getName(); URL jenkinsUpdateCenter = new URL("https://updates.jenkins.io/current/update-center.actual.json"); - Path cachePath = Paths.get("/path/to/cache"); - Path mavenHome = Paths.get("/path/to/maven"); + Path cachePath = Paths.get("path/to/cache"); + Path mavenHome = Paths.get("path/to/maven"); boolean dryRun = true; Config config = Config.builder() @@ -48,8 +48,8 @@ public void testConfigBuilderWithAllFields() throws MalformedURLException { assertEquals(plugins, config.getPlugins()); assertEquals(recipe, config.getRecipe()); assertEquals(jenkinsUpdateCenter, config.getJenkinsUpdateCenter()); - assertEquals(cachePath, config.getCachePath()); - assertEquals(mavenHome, config.getMavenHome()); + assertEquals(cachePath.toAbsolutePath(), config.getCachePath()); + assertEquals(mavenHome.toAbsolutePath(), config.getMavenHome()); assertTrue(config.isRemoveForks()); assertTrue(config.isRemoveForks()); assertTrue(config.isDryRun()); diff --git a/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/github/GHServiceTest.java b/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/github/GHServiceTest.java index 13e52b8c..335f6360 100644 --- a/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/github/GHServiceTest.java +++ b/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/github/GHServiceTest.java @@ -29,6 +29,7 @@ import java.util.List; import org.eclipse.jgit.api.CloneCommand; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.transport.CredentialsProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -55,6 +56,9 @@ public class GHServiceTest { @TempDir private Path pluginDir; + @TempDir + private Path sshDir; + /** * Tested instance */ @@ -573,7 +577,46 @@ public void shouldDeleteForkIfAllConditionsMet() throws Exception { } @Test - public void shouldFetchOriginalRepoInDryRunModeToNewFolder() throws Exception { + public void shouldSshFetchOriginalRepoInDryRunModeToNewFolder() throws Exception { + + // Mock + GHRepository repository = Mockito.mock(GHRepository.class); + Git git = Mockito.mock(Git.class); + CloneCommand cloneCommand = Mockito.mock(CloneCommand.class); + + // Use SSH key auth + Field field = ReflectionUtils.findFields( + GHService.class, + f -> f.getName().equals("sshKeyAuth"), + ReflectionUtils.HierarchyTraversalMode.TOP_DOWN) + .get(0); + field.setAccessible(true); + field.set(service, true); + + doReturn(true).when(config).isDryRun(); + doReturn("fake-repo").when(plugin).getRepositoryName(); + doReturn(repository).when(github).getRepository(eq("jenkinsci/fake-repo")); + doReturn(git).when(cloneCommand).call(); + doReturn("fake-url").when(repository).getSshUrl(); + doReturn(cloneCommand).when(cloneCommand).setRemote(eq("origin")); + doReturn(cloneCommand).when(cloneCommand).setURI(eq("ssh:///fake-url")); + doReturn(cloneCommand).when(cloneCommand).setCredentialsProvider(any(CredentialsProvider.class)); + doReturn(cloneCommand).when(cloneCommand).setDirectory(any(File.class)); + + // Directory doesn't exists + doReturn(Path.of("not-existing-dir")).when(plugin).getLocalRepository(); + + // Test + try (MockedStatic mockStaticGit = mockStatic(Git.class)) { + mockStaticGit.when(Git::cloneRepository).thenReturn(cloneCommand); + service.fetch(plugin); + verify(cloneCommand, times(1)).call(); + verifyNoMoreInteractions(cloneCommand); + } + } + + @Test + public void shouldHttpFetchOriginalRepoInDryRunModeToNewFolder() throws Exception { // Mock GHRepository repository = Mockito.mock(GHRepository.class); @@ -585,7 +628,9 @@ public void shouldFetchOriginalRepoInDryRunModeToNewFolder() throws Exception { doReturn(repository).when(github).getRepository(eq("jenkinsci/fake-repo")); doReturn(git).when(cloneCommand).call(); doReturn("fake-url").when(repository).getHttpTransportUrl(); + doReturn(cloneCommand).when(cloneCommand).setRemote(eq("origin")); doReturn(cloneCommand).when(cloneCommand).setURI(eq("fake-url")); + doReturn(cloneCommand).when(cloneCommand).setCredentialsProvider(any(CredentialsProvider.class)); doReturn(cloneCommand).when(cloneCommand).setDirectory(any(File.class)); // Directory doesn't exists @@ -601,21 +646,65 @@ public void shouldFetchOriginalRepoInDryRunModeToNewFolder() throws Exception { } @Test - public void shouldFetchOriginalRepoInMetaDataOnlyModeToNewFolder() throws Exception { + public void shouldSshFetchOriginalRepoInMetaDataOnlyModeToNewFolder() throws Exception { // Mock GHRepository repository = Mockito.mock(GHRepository.class); Git git = Mockito.mock(Git.class); CloneCommand cloneCommand = Mockito.mock(CloneCommand.class); + // Use SSH key auth + Field field = ReflectionUtils.findFields( + GHService.class, + f -> f.getName().equals("sshKeyAuth"), + ReflectionUtils.HierarchyTraversalMode.TOP_DOWN) + .get(0); + field.setAccessible(true); + field.set(service, true); + doReturn(false).when(config).isDryRun(); + + doReturn(true).when(config).isFetchMetadataOnly(); + doReturn("fake-repo").when(plugin).getRepositoryName(); + doReturn(repository).when(github).getRepository(eq("jenkinsci/fake-repo")); + doReturn(git).when(cloneCommand).call(); + doReturn("fake-url").when(repository).getSshUrl(); + doReturn(cloneCommand).when(cloneCommand).setRemote(eq("origin")); + doReturn(cloneCommand).when(cloneCommand).setURI(eq("ssh:///fake-url")); + doReturn(cloneCommand).when(cloneCommand).setDirectory(any(File.class)); + doReturn(cloneCommand).when(cloneCommand).setCredentialsProvider(any(CredentialsProvider.class)); + + // Directory doesn't exists + doReturn(Path.of("not-existing-dir")).when(plugin).getLocalRepository(); + + // Test + try (MockedStatic mockStaticGit = mockStatic(Git.class)) { + mockStaticGit.when(Git::cloneRepository).thenReturn(cloneCommand); + service.fetch(plugin); + verify(cloneCommand, times(1)).call(); + verifyNoMoreInteractions(cloneCommand); + } + } + + @Test + public void shouldHttpFetchOriginalRepoInMetaDataOnlyModeToNewFolder() throws Exception { + + // Mock + GHRepository repository = Mockito.mock(GHRepository.class); + Git git = Mockito.mock(Git.class); + CloneCommand cloneCommand = Mockito.mock(CloneCommand.class); + + doReturn(false).when(config).isDryRun(); + doReturn(true).when(config).isFetchMetadataOnly(); doReturn("fake-repo").when(plugin).getRepositoryName(); doReturn(repository).when(github).getRepository(eq("jenkinsci/fake-repo")); doReturn(git).when(cloneCommand).call(); doReturn("fake-url").when(repository).getHttpTransportUrl(); + doReturn(cloneCommand).when(cloneCommand).setRemote(eq("origin")); doReturn(cloneCommand).when(cloneCommand).setURI(eq("fake-url")); doReturn(cloneCommand).when(cloneCommand).setDirectory(any(File.class)); + doReturn(cloneCommand).when(cloneCommand).setCredentialsProvider(any(CredentialsProvider.class)); // Directory doesn't exists doReturn(Path.of("not-existing-dir")).when(plugin).getLocalRepository(); diff --git a/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/model/PluginTest.java b/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/model/PluginTest.java index a79bc51d..1b79cdf5 100644 --- a/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/model/PluginTest.java +++ b/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/model/PluginTest.java @@ -14,6 +14,7 @@ import io.jenkins.tools.pluginmodernizer.core.config.Settings; import io.jenkins.tools.pluginmodernizer.core.github.GHService; import io.jenkins.tools.pluginmodernizer.core.impl.MavenInvoker; +import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -50,11 +51,30 @@ public void testRepositoryName() { } @Test - public void testLocalRepository() { - Plugin plugin = Plugin.build("example"); + public void testDefaultLocalRepository() { + Plugin plugin = mock(Plugin.class); + doReturn("example").when(plugin).getName(); + Config config = mock(Config.class); + doReturn(Settings.DEFAULT_CACHE_PATH).when(config).getCachePath(); + doReturn(config).when(plugin).getConfig(); + assertEquals( + Settings.getPluginsDirectory(plugin).resolve("sources").toString(), + Settings.DEFAULT_CACHE_PATH + .resolve("example") + .resolve("sources") + .toString()); + } + + @Test + public void testCustomLocalRepository() { + Plugin plugin = mock(Plugin.class); + doReturn("example").when(plugin).getName(); + Config config = mock(Config.class); + doReturn(Path.of("my-cache")).when(config).getCachePath(); + doReturn(config).when(plugin).getConfig(); assertEquals( Settings.getPluginsDirectory(plugin).resolve("sources").toString(), - plugin.getLocalRepository().toString()); + Path.of("my-cache").resolve("example").resolve("sources").toString()); } @Test diff --git a/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/utils/PluginServiceTest.java b/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/utils/PluginServiceTest.java index c97546a2..ba3ad6cf 100644 --- a/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/utils/PluginServiceTest.java +++ b/plugin-modernizer-core/src/test/java/io/jenkins/tools/pluginmodernizer/core/utils/PluginServiceTest.java @@ -62,6 +62,10 @@ public void setup() throws Exception { "valid-plugin", new UpdateCenterData.UpdateCenterPlugin( "valid-plugin", "1.0", "https://github.com/jenkinsci/valid-url", "main", "gav", null)); + updateCenterPlugins.put( + "valid-plugin-2", + new UpdateCenterData.UpdateCenterPlugin( + "valid-plugin", "1.0", "git@github.com/jenkinsci/valid-git-repo.git", "main", "gav", null)); updateCenterPlugins.put( "invalid-plugin", new UpdateCenterData.UpdateCenterPlugin( @@ -116,6 +120,14 @@ public void shouldExtractRepoName() throws Exception { assertEquals("valid-url", result); } + @Test + public void shouldExtractRepoNameWithGitSuffix() throws Exception { + setupUpdateCenterMocks(); + PluginService service = getService(); + String result = service.extractRepoName(Plugin.build("valid-plugin-2").withConfig(config)); + assertEquals("valid-git-repo", result); + } + @Test public void shouldDownloadPluginVersionDataUpdateCenterData(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { diff --git a/pom.xml b/pom.xml index 48e27fa7..b494fbf0 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,8 @@ 3.5.2 1.20.4 0.10.0 + 2.14.0 + 3.3.0 @@ -197,6 +199,21 @@ maven-artifact ${maven.version} + + org.apache.maven.shared + maven-invoker + ${maven.invoker.version} + + + org.apache.sshd + sshd-core + ${apache.mina.version} + + + org.apache.sshd + sshd-git + ${apache.mina.version} + org.bouncycastle bcpkix-jdk18on @@ -207,6 +224,11 @@ bcprov-jdk18on ${bouncycastle.version} + + org.bouncycastle + bcutil-jdk18on + ${bouncycastle.version} + org.checkerframework checker-qual @@ -217,6 +239,11 @@ org.eclipse.jgit ${jgit.version} + + org.eclipse.jgit + org.eclipse.jgit.ssh.apache + ${jgit.version} + org.kohsuke github-api @@ -283,6 +310,11 @@ junit-jupiter-engine test + + org.junit.jupiter + junit-jupiter-params + test + org.mockito mockito-core