diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java index ff893ec147..60f38dc1d6 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java @@ -1,7 +1,8 @@ package io.jenkins.plugins.casc; import static io.jenkins.plugins.casc.SchemaGeneration.writeJSONSchema; -import static java.lang.String.format; +import static io.jenkins.plugins.casc.fetcher.FetchCredentials.resolveAll; +import static java.util.Collections.unmodifiableList; import static java.util.stream.Collectors.toList; import static org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK; import static org.yaml.snakeyaml.DumperOptions.ScalarStyle.DOUBLE_QUOTED; @@ -22,6 +23,9 @@ import hudson.security.ACLContext; import hudson.security.Permission; import hudson.util.FormValidation; +import io.jenkins.plugins.casc.fetcher.CasCConfigFetcher; +import io.jenkins.plugins.casc.fetcher.FetchContext; +import io.jenkins.plugins.casc.fetcher.FetchCredentials; import io.jenkins.plugins.casc.impl.DefaultConfiguratorRegistry; import io.jenkins.plugins.casc.model.CNode; import io.jenkins.plugins.casc.model.Mapping; @@ -43,8 +47,6 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; -import java.net.URI; -import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; @@ -232,20 +234,23 @@ public void doReplace(StaplerRequest2 request, StaplerResponse2 response) throws } } if (!candidateSources.isEmpty()) { - List candidates = getConfigFromSources(candidateSources); - if (canApplyFrom(candidates)) { - sources = candidateSources; - configureWith(getConfigFromSources(getSources())); - CasCGlobalConfig config = GlobalConfiguration.all().get(CasCGlobalConfig.class); - if (config != null) { - config.setConfigurationPath(normalizedSource); - config.save(); + try (FetchContext context = getConfigFromSources(candidateSources)) { + if (canApplyFrom(context.getSources())) { + sources = unmodifiableList(candidateSources); + try (FetchContext applyContext = getConfigFromSources(getSources())) { + configureWith(applyContext.getSources()); + } + CasCGlobalConfig config = GlobalConfiguration.all().get(CasCGlobalConfig.class); + if (config != null) { + config.setConfigurationPath(normalizedSource); + config.save(); + } + LOGGER.log(Level.FINE, "Replace configuration with: " + normalizedSource); + } else { + LOGGER.log(Level.WARNING, "Provided sources could not be applied"); + throw new ConfiguratorException( + "Provided sources could not be applied. Please check the syntax and validity of the provided configuration."); } - LOGGER.log(Level.FINE, "Replace configuration with: " + normalizedSource); - } else { - LOGGER.log(Level.WARNING, "Provided sources could not be applied"); - throw new ConfiguratorException( - "Provided sources could not be applied. Please check the syntax and validity of the provided configuration."); } } else { LOGGER.log(Level.FINE, "No such source exists, applying default"); @@ -294,9 +299,8 @@ public FormValidation doCheckNewSource(@QueryParameter String newSource) { } candidateSources.add(candidateSource); } - try { - List candidates = getConfigFromSources(candidateSources); - final Map issues = checkWith(candidates); + try (FetchContext context = getConfigFromSources(candidateSources)) { + final Map issues = checkWith(context.getSources()); final JSONArray errors = collectProblems(issues, "error"); if (!errors.isEmpty()) { return FormValidation.error(errors.toString()); @@ -305,10 +309,16 @@ public FormValidation doCheckNewSource(@QueryParameter String newSource) { if (!warnings.isEmpty()) { return FormValidation.warning(warnings.toString()); } - return FormValidation.okWithMarkup("The configuration can be applied"); - } catch (ConfiguratorException | IllegalArgumentException e) { - return FormValidation.error( - e, e.getCause() == null ? e.getMessage() : e.getCause().getMessage()); + return FormValidation.ok("The configuration can be applied"); + } catch (Exception e) { + String errorMessage = e.getMessage(); + if (errorMessage == null && e.getCause() != null) { + errorMessage = e.getCause().getMessage(); + } + if (errorMessage == null) { + errorMessage = "An unexpected " + e.getClass().getSimpleName() + " occurred."; + } + return FormValidation.error("Invalid configuration: " + errorMessage); } } @@ -326,21 +336,42 @@ private JSONArray collectProblems(Map issues, String severity) { return problems; } - private void appendSources(List sources, String source) throws ConfiguratorException { - if (isSupportedURI(source)) { - sources.add(YamlSource.of(source)); - } else { - sources.addAll(configs(source).stream().map(YamlSource::of).collect(toList())); - } - } + private FetchContext getConfigFromSources(List newSources) throws ConfiguratorException { + FetchContext context = new FetchContext(); + boolean success = false; - private List getConfigFromSources(List newSources) throws ConfiguratorException { - List sources = new ArrayList<>(); + try { + FetchCredentials credentials = resolveAll(); + + for (String p : newSources) { + boolean fetched = false; + + for (CasCConfigFetcher fetcher : Jenkins.get().getExtensionList(CasCConfigFetcher.class)) { + if (fetcher.supports(p)) { + try { + context.add(fetcher.fetch(p, credentials)); + fetched = true; + break; + } catch (IOException e) { + throw new ConfiguratorException("Failed to fetch configuration from " + p, e); + } + } + } + + if (!fetched) { + throw new ConfiguratorException("Source '" + p + + "' is not supported by any registered configuration fetcher or does not exist."); + } + } - for (String p : newSources) { - appendSources(sources, p); + success = true; + return context; + + } finally { + if (!success) { + context.close(); + } } - return sources; } /** @@ -367,18 +398,16 @@ public static void init() throws Exception { * @throws ConfiguratorException Configuration error */ public void configure() throws ConfiguratorException { - configureWith(getStandardConfigSources()); + try (FetchContext context = getStandardConfigSources()) { + configureWith(context.getSources()); + } } - private List getStandardConfigSources() throws ConfiguratorException { - List configs = new ArrayList<>(); - + private FetchContext getStandardConfigSources() throws ConfiguratorException { List standardConfig = getStandardConfig(); - for (String p : standardConfig) { - appendSources(configs, p); - } - sources = Collections.unmodifiableList(standardConfig); - return configs; + FetchContext context = getConfigFromSources(standardConfig); + sources = unmodifiableList(standardConfig); + return context; } private List getStandardConfig() { @@ -710,38 +739,35 @@ public void configure(String... configParameters) throws ConfiguratorException { public void configure(Collection configParameters) throws ConfiguratorException { - List configs = new ArrayList<>(); + List newSources = new ArrayList<>(configParameters); - for (String p : configParameters) { - appendSources(configs, p); + try (FetchContext context = getConfigFromSources(newSources)) { + sources = unmodifiableList(newSources); + configureWith(context.getSources()); + lastTimeLoaded = System.currentTimeMillis(); } - sources = Collections.unmodifiableList(new ArrayList<>(configParameters)); - configureWith(configs); - lastTimeLoaded = System.currentTimeMillis(); } public static boolean isSupportedURI(String configurationParameter) { if (configurationParameter == null) { return false; } - final List supportedProtocols = Arrays.asList("https", "http", "file"); - URI uri; - try { - uri = new URI(configurationParameter); - } catch (URISyntaxException ex) { - return false; - } - if (uri.getScheme() == null) { - return false; + for (CasCConfigFetcher fetcher : Jenkins.get().getExtensionList(CasCConfigFetcher.class)) { + if (fetcher.supports(configurationParameter)) { + return true; + } } - return supportedProtocols.contains(uri.getScheme()); + return false; } @Restricted(NoExternalUse.class) + @SuppressWarnings("rawtypes") public void configureWith(YamlSource source) throws ConfiguratorException { - final List sources = getStandardConfigSources(); - sources.add(source); - configureWith(sources); + try (FetchContext context = getStandardConfigSources()) { + List allSources = new ArrayList<>(context.getSources()); + allSources.add(source); + configureWith(allSources); + } } private void configureWith(List sources) throws ConfiguratorException { diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/BootstrapEnvVarCredentialResolver.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/BootstrapEnvVarCredentialResolver.java new file mode 100644 index 0000000000..874c5025d2 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/BootstrapEnvVarCredentialResolver.java @@ -0,0 +1,46 @@ +package io.jenkins.plugins.casc.fetcher; + +public final class BootstrapEnvVarCredentialResolver { + + public static final BootstrapEnvVarCredentialResolver INSTANCE = new BootstrapEnvVarCredentialResolver(); + + private BootstrapEnvVarCredentialResolver() {} + + @SuppressWarnings("unchecked") + public T resolve(String credentialId, Class type) { + if (credentialId == null || credentialId.isEmpty() || type == null) { + return null; + } + if (type == FetchAuthData.Token.class) { + String token = System.getenv(credentialId); + if (token != null && !token.isEmpty()) { + return (T) (FetchAuthData.Token) () -> token; + } + } + + if (type == FetchAuthData.SshKey.class) { + String privateKey = System.getenv(credentialId + "_PRIVATE_KEY"); + if (privateKey != null && !privateKey.isEmpty()) { + String username = System.getenv(credentialId + "_USERNAME"); + String passphrase = System.getenv(credentialId + "_PASSPHRASE"); + return (T) new FetchAuthData.SshKey() { + @Override + public String getUsername() { + return username != null ? username : "git"; + } + + @Override + public String getPrivateKey() { + return privateKey; + } + + @Override + public String getPassphrase() { + return passphrase; + } + }; + } + } + return null; + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/CasCConfigFetcher.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/CasCConfigFetcher.java new file mode 100644 index 0000000000..730cefaa8c --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/CasCConfigFetcher.java @@ -0,0 +1,11 @@ +package io.jenkins.plugins.casc.fetcher; + +import hudson.ExtensionPoint; +import java.io.IOException; + +public interface CasCConfigFetcher extends ExtensionPoint { + + boolean supports(String location); + + FetchResult fetch(String location, FetchCredentials credentials) throws IOException; +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/DefaultHttpFetcher.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/DefaultHttpFetcher.java new file mode 100644 index 0000000000..6208973bcc --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/DefaultHttpFetcher.java @@ -0,0 +1,67 @@ +package io.jenkins.plugins.casc.fetcher; + +import static java.lang.Thread.currentThread; + +import hudson.Extension; +import hudson.ProxyConfiguration; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; + +@Extension(ordinal = -100) +public class DefaultHttpFetcher implements CasCConfigFetcher { + + @Override + public boolean supports(String location) { + return location != null && (location.startsWith("http://") || location.startsWith("https://")); + } + + @Override + public FetchResult fetch(String location, FetchCredentials credentials) throws IOException { + URI uri; + try { + uri = new URI(location); + } catch (URISyntaxException e) { + throw new IOException("Invalid URL: " + location, e); + } + + String path = uri.getPath(); + String fileName = + (path != null && path.contains("/")) ? path.substring(path.lastIndexOf('/') + 1) : "casc.yaml"; + + if (fileName.isEmpty()) { + fileName = "casc.yaml"; + } + + HttpClient client = ProxyConfiguration.newHttpClient(); + + HttpRequest request = ProxyConfiguration.newHttpRequestBuilder(uri) + .GET() + .timeout(Duration.ofSeconds(30)) + .build(); + + byte[] yamlBytes; + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Failed to fetch configuration from " + location + ". HTTP status code: " + + response.statusCode()); + } + + yamlBytes = response.body(); + } catch (InterruptedException e) { + currentThread().interrupt(); + throw new IOException("Interrupted while fetching configuration from: " + location, e); + } + + ResolvedYaml resolved = new ResolvedYaml(fileName, () -> new java.io.ByteArrayInputStream(yamlBytes)); + + return new FetchResult(Collections.singletonList(resolved), (AutoCloseable) null); + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/EnvVarFetchCredentialsProvider.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/EnvVarFetchCredentialsProvider.java new file mode 100644 index 0000000000..8411f66478 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/EnvVarFetchCredentialsProvider.java @@ -0,0 +1,12 @@ +package io.jenkins.plugins.casc.fetcher; + +import hudson.Extension; + +@Extension(ordinal = -100) +public class EnvVarFetchCredentialsProvider implements FetchCredentialsProvider { + + @Override + public T getCredentials(String credentialId, Class type) { + return BootstrapEnvVarCredentialResolver.INSTANCE.resolve(credentialId, type); + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchAuthData.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchAuthData.java new file mode 100644 index 0000000000..d94afc5bc3 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchAuthData.java @@ -0,0 +1,25 @@ +package io.jenkins.plugins.casc.fetcher; + +import edu.umd.cs.findbugs.annotations.CheckForNull; + +public interface FetchAuthData { + + interface Token extends FetchAuthData { + String getToken(); + } + + interface UsernamePassword extends FetchAuthData { + String getUsername(); + + String getPassword(); + } + + interface SshKey extends FetchAuthData { + String getUsername(); + + String getPrivateKey(); + + @CheckForNull + String getPassphrase(); + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchContext.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchContext.java new file mode 100644 index 0000000000..1adad9e575 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchContext.java @@ -0,0 +1,53 @@ +package io.jenkins.plugins.casc.fetcher; + +import static java.util.Collections.unmodifiableList; + +import io.jenkins.plugins.casc.yaml.YamlSource; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class FetchContext implements AutoCloseable { + + private static final Logger LOGGER = Logger.getLogger(FetchContext.class.getName()); + + private final List results = new ArrayList<>(); + + @SuppressWarnings("rawtypes") + private final List yamlSources = new ArrayList<>(); + + public void add(FetchResult result) { + if (result == null) { + return; + } + + this.results.add(result); + List items = new ArrayList<>(result.items()); + + items.sort(java.util.Comparator.comparing(ResolvedYaml::relativePath)); + + for (ResolvedYaml item : items) { + yamlSources.add(YamlSource.of(item)); + } + } + + @SuppressWarnings("rawtypes") + public List getSources() { + return unmodifiableList(yamlSources); + } + + @Override + public void close() { + for (FetchResult result : results) { + try { + if (result != null) { + result.close(); + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to clean up JCasC fetch result", e); + } + } + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchCredentials.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchCredentials.java new file mode 100644 index 0000000000..5b012419fa --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchCredentials.java @@ -0,0 +1,42 @@ +package io.jenkins.plugins.casc.fetcher; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; + +public interface FetchCredentials { + + Logger LOGGER = Logger.getLogger(FetchCredentials.class.getName()); + + @CheckForNull + T get(String credentialId, Class type); + + static FetchCredentials resolveAll() { + return new FetchCredentials() { + @Override + public T get(String credentialId, Class type) { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins != null) { + for (FetchCredentialsProvider provider : jenkins.getExtensionList(FetchCredentialsProvider.class)) { + try { + T authData = provider.getCredentials(credentialId, type); + if (authData != null) { + return authData; + } + } catch (Exception e) { + LOGGER.log( + Level.FINE, + e, + () -> "Credential provider " + + provider.getClass().getName() + + " threw an exception while resolving ID: " + credentialId); + } + } + } + + return BootstrapEnvVarCredentialResolver.INSTANCE.resolve(credentialId, type); + } + }; + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsProvider.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsProvider.java new file mode 100644 index 0000000000..235b0984cd --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsProvider.java @@ -0,0 +1,10 @@ +package io.jenkins.plugins.casc.fetcher; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import hudson.ExtensionPoint; + +public interface FetchCredentialsProvider extends ExtensionPoint { + + @CheckForNull + T getCredentials(String credentialId, Class type); +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchResult.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchResult.java new file mode 100644 index 0000000000..81bcea29b9 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/FetchResult.java @@ -0,0 +1,63 @@ +package io.jenkins.plugins.casc.fetcher; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class FetchResult implements AutoCloseable { + + private final List items; + private final List resourcesToClose = new ArrayList<>(); + private final List pathsToDelete = new ArrayList<>(); + + public FetchResult(List items, AutoCloseable resource) { + this.items = items != null ? Collections.unmodifiableList(items) : Collections.emptyList(); + if (resource != null) { + this.resourcesToClose.add(resource); + } + } + + public FetchResult(List items, Path tempDirectory) { + this.items = items != null ? Collections.unmodifiableList(items) : Collections.emptyList(); + if (tempDirectory != null) { + this.pathsToDelete.add(tempDirectory); + } + } + + public List items() { + return items; + } + + @Override + public void close() throws IOException { + for (AutoCloseable resource : resourcesToClose) { + if (resource == null) { + continue; + } + try { + resource.close(); + } catch (Exception e) { + throw new IOException("Failed to close fetched resource", e); + } + } + + for (Path tempDirectory : pathsToDelete) { + if (tempDirectory == null || !Files.exists(tempDirectory)) { + continue; + } + + try (var stream = Files.walk(tempDirectory)) { + List paths = + stream.sorted(java.util.Comparator.reverseOrder()).toList(); + for (Path p : paths) { + Files.delete(p); + } + } catch (Exception e) { + throw new IOException("Failed to clean up fetched directory: " + tempDirectory, e); + } + } + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/LocalFileSystemFetcher.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/LocalFileSystemFetcher.java new file mode 100644 index 0000000000..a6819dec63 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/LocalFileSystemFetcher.java @@ -0,0 +1,83 @@ +package io.jenkins.plugins.casc.fetcher; + +import hudson.Extension; +import java.io.IOException; +import java.net.URI; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +@Extension(ordinal = -100) +public class LocalFileSystemFetcher implements CasCConfigFetcher { + + private static final String YAML_FILES_PATTERN = "glob:**.{yml,yaml,YAML,YML}"; + + @Override + public boolean supports(String location) { + if (location == null) { + return false; + } + if (location.startsWith("file:")) { + return true; + } + try { + return Files.exists(Paths.get(location)); + } catch (Exception e) { + return false; + } + } + + @Override + public FetchResult fetch(String location, FetchCredentials credentials) throws IOException { + final Path root; + if (location.startsWith("file:")) { + try { + root = Paths.get(URI.create(location)); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid file URI format: " + location, e); + } + } else { + root = Paths.get(location); + } + + if (!Files.exists(root)) { + throw new IOException("Invalid configuration: '" + root + "' isn't a valid path."); + } + + if (Files.isRegularFile(root) && Files.isReadable(root)) { + Path fileNamePath = root.getFileName(); + String fileName = fileNamePath != null ? fileNamePath.toString() : root.toString(); + ResolvedYaml resolved = new ResolvedYaml(fileName, () -> Files.newInputStream(root)); + return new FetchResult(Collections.singletonList(resolved), (AutoCloseable) null); + } + + final PathMatcher matcher = FileSystems.getDefault().getPathMatcher(YAML_FILES_PATTERN); + try (Stream stream = Files.find( + root, + Integer.MAX_VALUE, + (next, attrs) -> !attrs.isDirectory() && !isHidden(next) && matcher.matches(next), + FileVisitOption.FOLLOW_LINKS)) { + + List items = stream.map(path -> { + String relativePath = root.relativize(path).toString().replace('\\', '/'); + return new ResolvedYaml(relativePath, () -> Files.newInputStream(path)); + }) + .collect(Collectors.toList()); + + return new FetchResult(items, (AutoCloseable) null); + } + } + + private static boolean isHidden(Path path) { + return IntStream.range(0, path.getNameCount()).mapToObj(path::getName).anyMatch(subPath -> subPath.toString() + .startsWith(".")); + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/ResolvedYaml.java b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/ResolvedYaml.java new file mode 100644 index 0000000000..2b9e7940f0 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/fetcher/ResolvedYaml.java @@ -0,0 +1,34 @@ +package io.jenkins.plugins.casc.fetcher; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +public final class ResolvedYaml { + + private final String relativePath; + private final StreamSupplier streamSupplier; + + @FunctionalInterface + public interface StreamSupplier { + InputStream open() throws IOException; + } + + public ResolvedYaml(String relativePath, StreamSupplier streamSupplier) { + this.relativePath = Objects.requireNonNull(relativePath, "relativePath cannot be null"); + this.streamSupplier = Objects.requireNonNull(streamSupplier, "streamSupplier cannot be null"); + } + + public String relativePath() { + return relativePath; + } + + public InputStream open() throws IOException { + return streamSupplier.open(); + } + + @Override + public String toString() { + return "ResolvedYaml{relativePath='" + relativePath + "'}"; + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlSource.java b/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlSource.java index a0888c3771..83c2baf7ce 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlSource.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlSource.java @@ -1,5 +1,6 @@ package io.jenkins.plugins.casc.yaml; +import io.jenkins.plugins.casc.fetcher.ResolvedYaml; import jakarta.servlet.http.HttpServletRequest; import java.io.InputStream; import java.nio.file.Path; @@ -10,15 +11,25 @@ public class YamlSource { public final T source; + private String name; public YamlSource(T source) { this.source = source; } + public YamlSource(T source, String name) { + this.source = source; + this.name = name; + } + public static YamlSource of(InputStream in) { return new YamlSource<>(in); } + public static YamlSource of(InputStream in, String name) { + return new YamlSource<>(in, name); + } + public static YamlSource of(String url) { return new YamlSource<>(url); } @@ -31,7 +42,18 @@ public static YamlSource of(Path path) { return new YamlSource<>(path); } + public static YamlSource of(Path path, String name) { + return new YamlSource<>(path, name); + } + + public static YamlSource of(ResolvedYaml resolvedYaml) { + return new YamlSource<>(resolvedYaml, resolvedYaml.relativePath()); + } + public String source() { + if (name != null) { + return name; + } if (source instanceof HttpServletRequest) { return ((HttpServletRequest) source).getPathInfo(); } diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java b/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java index cd64c5dada..e3424a0084 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java @@ -5,6 +5,7 @@ import io.jenkins.plugins.casc.ConfigurationAsCode; import io.jenkins.plugins.casc.ConfigurationContext; import io.jenkins.plugins.casc.ConfiguratorException; +import io.jenkins.plugins.casc.fetcher.ResolvedYaml; import io.jenkins.plugins.casc.model.Mapping; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; @@ -78,7 +79,9 @@ public static Node read(YamlSource source, Reader reader, ConfigurationContext c public static Reader reader(YamlSource source) throws IOException { Object src = source.source; - if (src instanceof String) { + if (src instanceof ResolvedYaml) { + return new InputStreamReader(((ResolvedYaml) src).open(), UTF_8); + } else if (src instanceof String) { final URL url = URI.create((String) src).toURL(); return new InputStreamReader(url.openStream(), UTF_8); } else if (src instanceof InputStream) { diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/BootstrapEnvVarCredentialResolverTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/BootstrapEnvVarCredentialResolverTest.java new file mode 100644 index 0000000000..828eac448f --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/BootstrapEnvVarCredentialResolverTest.java @@ -0,0 +1,77 @@ +package io.jenkins.plugins.casc.fetcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assume.assumeTrue; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.contrib.java.lang.system.EnvironmentVariables; + +public class BootstrapEnvVarCredentialResolverTest { + + @Rule + public final EnvironmentVariables environment = new EnvironmentVariables(); + + private final BootstrapEnvVarCredentialResolver resolver = BootstrapEnvVarCredentialResolver.INSTANCE; + + @Test + public void testResolveWithInvalidInputsReturnsNull() { + assertNull(resolver.resolve(null, FetchAuthData.Token.class)); + assertNull(resolver.resolve("", FetchAuthData.Token.class)); + assertNull(resolver.resolve("VALID_ID", null)); + } + + @Test + public void testMissingVariablesReturnNull() { + assertNull(resolver.resolve("NON_EXISTENT_TOKEN", FetchAuthData.Token.class)); + assertNull(resolver.resolve("NON_EXISTENT_SSH", FetchAuthData.SshKey.class)); + } + + @Test + public void testUnsupportedTypeReturnsNull() { + FetchAuthData.UsernamePassword unsupported = resolver.resolve("SOME_ID", FetchAuthData.UsernamePassword.class); + assertNull(unsupported); + } + + @Test + public void testTokenResolutionWithExistingEnvVar() { + String expectedToken = System.getenv("PATH"); + + assumeTrue(expectedToken != null && !expectedToken.isEmpty()); + + FetchAuthData.Token tokenAuth = resolver.resolve("PATH", FetchAuthData.Token.class); + + assertNotNull(tokenAuth); + assertEquals(expectedToken, tokenAuth.getToken()); + } + + @Test + public void testSshKeyResolutionWithAllVars() { + environment.set("MY_SSH_PRIVATE_KEY", "dummy-key-data"); + environment.set("MY_SSH_USERNAME", "custom-user"); + environment.set("MY_SSH_PASSPHRASE", "super-secret"); + + FetchAuthData.SshKey sshKey = resolver.resolve("MY_SSH", FetchAuthData.SshKey.class); + + assertNotNull(sshKey); + assertEquals("dummy-key-data", sshKey.getPrivateKey()); + assertEquals("custom-user", sshKey.getUsername()); + assertEquals("super-secret", sshKey.getPassphrase()); + } + + @Test + public void testSshKeyResolutionWithDefaultUsername() { + environment.set("DEFAULT_SSH_PRIVATE_KEY", "dummy-key-data-2"); + + FetchAuthData.SshKey sshKey = resolver.resolve("DEFAULT_SSH", FetchAuthData.SshKey.class); + + assertNotNull(sshKey); + assertEquals("dummy-key-data-2", sshKey.getPrivateKey()); + + assertEquals("git", sshKey.getUsername()); + + assertNull(sshKey.getPassphrase()); + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/ConfigFetcherIntegrationTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/ConfigFetcherIntegrationTest.java new file mode 100644 index 0000000000..49b60ae6bc --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/ConfigFetcherIntegrationTest.java @@ -0,0 +1,65 @@ +package io.jenkins.plugins.casc.fetcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import io.jenkins.plugins.casc.ConfigurationAsCode; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TestExtension; + +public class ConfigFetcherIntegrationTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + public void testCustomFetcherEndToEndLifecycle() { + ConfigurationAsCode casc = ConfigurationAsCode.get(); + + casc.configure("mock-fetcher://production/jenkins.yaml"); + + List activeSources = casc.getSources(); + assertEquals(1, activeSources.size()); + assertEquals("mock-fetcher://production/jenkins.yaml", activeSources.get(0)); + assertTrue("The AutoCloseable lifecycle must invoke close() on our FetchResult", MockConfigFetcher.wasClosed); + } + + @TestExtension + public static class MockConfigFetcher implements CasCConfigFetcher { + + public static boolean wasClosed = false; + + @Override + public boolean supports(String location) { + return location != null && location.startsWith("mock-fetcher://"); + } + + @Override + public FetchResult fetch(String location, FetchCredentials credentials) { + wasClosed = false; + + String dummyYaml = """ + jenkins: + systemMessage: "Configured seamlessly via physical Integration Tests!" + """; + + InputStream contentStream = new ByteArrayInputStream(dummyYaml.getBytes(StandardCharsets.UTF_8)); + + ResolvedYaml resolvedItem = new ResolvedYaml("jenkins.yaml", () -> contentStream); + + AutoCloseable resourceTracker = () -> { + contentStream.close(); + wasClosed = true; + }; + + return new FetchResult(Collections.singletonList(resolvedItem), resourceTracker); + } + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/DefaultHttpFetcherTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/DefaultHttpFetcherTest.java new file mode 100644 index 0000000000..59cba3d024 --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/DefaultHttpFetcherTest.java @@ -0,0 +1,179 @@ +package io.jenkins.plugins.casc.fetcher; + +import static java.lang.Thread.currentThread; +import static java.lang.Thread.interrupted; +import static java.lang.Thread.sleep; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.sun.net.httpserver.HttpServer; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.stream.Collectors; +import org.junit.Test; + +public class DefaultHttpFetcherTest { + + private final DefaultHttpFetcher fetcher = new DefaultHttpFetcher(); + + @Test + public void testSupportsLogic() { + assertTrue(fetcher.supports("http://example.com/casc.yaml")); + assertTrue(fetcher.supports("https://github.com/org/repo/jenkins.yml")); + assertFalse(fetcher.supports("file:///local/path")); + assertFalse(fetcher.supports(null)); + } + + @Test(expected = IOException.class) + public void testFetchThrowsOnInvalidUrl() throws Exception { + fetcher.fetch("http://example.com/invalid path with spaces", null); + } + + @Test + public void testFilenameExtraction() { + assertEquals("casc.yaml", extractFilename("http://example.com")); + assertEquals("casc.yaml", extractFilename("http://example.com/")); + assertEquals("jenkins.yaml", extractFilename("https://example.com/path/to/jenkins.yaml")); + assertEquals("config.yml", extractFilename("https://example.com/config.yml?token=123")); + } + + @Test + public void testActualHttpFetchIntegration() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/casc.yaml", exchange -> { + byte[] response = "jenkins:\n systemMessage: 'Hello HTTP'".getBytes(UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + + try { + int port = server.getAddress().getPort(); + String targetUrl = "http://localhost:" + port + "/casc.yaml"; + + FetchResult result = fetcher.fetch(targetUrl, null); + + assertEquals(1, result.items().size()); + ResolvedYaml yaml = result.items().get(0); + + assertEquals("casc.yaml", yaml.relativePath()); + + String content = new BufferedReader(new InputStreamReader(yaml.open(), UTF_8)) + .lines() + .collect(Collectors.joining("\n")); + + assertEquals("jenkins:\n systemMessage: 'Hello HTTP'", content); + + } finally { + server.stop(0); + } + } + + @Test + public void testFetchWithEmptyFileNameUsesDefault() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", exchange -> { + byte[] response = "jenkins:\n systemMessage: 'Fallback'".getBytes(UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + + try { + int port = server.getAddress().getPort(); + String targetUrl = "http://localhost:" + port + "/"; + + FetchResult result = fetcher.fetch(targetUrl, null); + + assertEquals(1, result.items().size()); + ResolvedYaml yaml = result.items().get(0); + + assertEquals("casc.yaml", yaml.relativePath()); + + } finally { + server.stop(0); + } + } + + @Test + public void testFetchThrowsOnHttpError() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/404", exchange -> { + exchange.sendResponseHeaders(404, -1); + exchange.close(); + }); + server.start(); + + try { + int port = server.getAddress().getPort(); + String targetUrl = "http://localhost:" + port + "/404"; + + fetcher.fetch(targetUrl, null); + fail("Expected fetcher to throw IOException due to HTTP 404 status"); + } catch (IOException e) { + assertTrue( + "Message should contain the HTTP status code", + e.getMessage().contains("HTTP status code: 404")); + } finally { + server.stop(0); + } + } + + @Test + public void testFetchThrowsOnInterruption() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/slow", exchange -> { + try { + sleep(1000); + } catch (InterruptedException ignored) { + } + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + server.start(); + + try { + int port = server.getAddress().getPort(); + String targetUrl = "http://localhost:" + port + "/slow"; + + currentThread().interrupt(); + + fetcher.fetch(targetUrl, null); + fail("Expected fetcher to throw IOException due to thread interruption"); + } catch (IOException e) { + assertTrue( + "Message should indicate the thread was interrupted", + e.getMessage().contains("Interrupted while fetching")); + + assertTrue( + "Thread interrupt flag should be restored", currentThread().isInterrupted()); + } finally { + @SuppressWarnings("unused") + boolean cleared = interrupted(); + + server.stop(0); + } + } + + private String extractFilename(String location) { + try { + URI uri = new URI(location); + String path = uri.getPath(); + if (path != null && path.contains("/")) { + String name = path.substring(path.lastIndexOf('/') + 1); + return name.isEmpty() ? "casc.yaml" : name; + } + return "casc.yaml"; + } catch (Exception e) { + return "casc.yaml"; + } + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/EnvVarFetchCredentialsProviderTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/EnvVarFetchCredentialsProviderTest.java new file mode 100644 index 0000000000..4b2add973e --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/EnvVarFetchCredentialsProviderTest.java @@ -0,0 +1,26 @@ +package io.jenkins.plugins.casc.fetcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assume.assumeTrue; + +import org.junit.Test; + +public class EnvVarFetchCredentialsProviderTest { + + @Test + public void testGetCredentialsDelegatesToBootstrapResolver() { + EnvVarFetchCredentialsProvider provider = new EnvVarFetchCredentialsProvider(); + + assertNull(provider.getCredentials("NON_EXISTENT_ID", FetchAuthData.Token.class)); + + String expectedToken = System.getenv("PATH"); + assumeTrue(expectedToken != null && !expectedToken.isEmpty()); + + FetchAuthData.Token tokenAuth = provider.getCredentials("PATH", FetchAuthData.Token.class); + + assertNotNull(tokenAuth); + assertEquals(expectedToken, tokenAuth.getToken()); + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchContextTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchContextTest.java new file mode 100644 index 0000000000..a0bbba1f9d --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchContextTest.java @@ -0,0 +1,74 @@ +package io.jenkins.plugins.casc.fetcher; + +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; + +public class FetchContextTest { + + @Test + public void testDeterministicSorting() { + ResolvedYaml yamlB = new ResolvedYaml("z-last.yaml", () -> new ByteArrayInputStream(new byte[0])); + ResolvedYaml yamlA = new ResolvedYaml("a-first.yaml", () -> new ByteArrayInputStream(new byte[0])); + + FetchResult result = new FetchResult(Arrays.asList(yamlB, yamlA), (AutoCloseable) null); + + try (FetchContext context = new FetchContext()) { + context.add(result); + + String firstSource = context.getSources().get(0).toString(); + String secondSource = context.getSources().get(1).toString(); + + assertTrue("a-first.yaml should be sorted first", firstSource.contains("a-first.yaml")); + assertTrue("z-last.yaml should be sorted last", secondSource.contains("z-last.yaml")); + } + } + + @Test + public void testContextClosesAllResults() { + AtomicBoolean result1Closed = new AtomicBoolean(false); + AtomicBoolean result2Closed = new AtomicBoolean(false); + + FetchResult result1 = new FetchResult(null, () -> result1Closed.set(true)); + FetchResult result2 = new FetchResult(null, () -> result2Closed.set(true)); + + FetchContext context = new FetchContext(); + context.add(result1); + context.add(result2); + context.close(); + + assertTrue("FetchContext must close the first FetchResult", result1Closed.get()); + assertTrue("FetchContext must close the second FetchResult", result2Closed.get()); + } + + @Test + public void testAddNullResultIsIgnored() { + try (FetchContext context = new FetchContext()) { + context.add(null); + assertTrue( + "Sources should remain empty when a null result is added", + context.getSources().isEmpty()); + } + } + + @Test + public void testCloseHandlesIOExceptionAndContinues() { + AtomicBoolean secondResultClosed = new AtomicBoolean(false); + + FetchResult failingResult = new FetchResult(null, () -> { + throw new java.io.IOException("Simulated cleanup failure"); + }); + + FetchResult succeedingResult = new FetchResult(null, () -> secondResultClosed.set(true)); + + FetchContext context = new FetchContext(); + context.add(failingResult); + context.add(succeedingResult); + context.close(); + + assertTrue("FetchContext must continue closing remaining results after an exception", secondResultClosed.get()); + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsChainTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsChainTest.java new file mode 100644 index 0000000000..dc3cf3feab --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsChainTest.java @@ -0,0 +1,63 @@ +package io.jenkins.plugins.casc.fetcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.util.logging.Level; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.TestExtension; + +public class FetchCredentialsChainTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Rule + public LoggerRule logging = new LoggerRule().record(FetchCredentials.class, Level.FINE); + + @Test + public void testProviderSuccessfullyResolvesCredential() { + FetchCredentials credentials = FetchCredentials.resolveAll(); + FetchAuthData.Token tokenAuth = credentials.get("dummy-id", FetchAuthData.Token.class); + + assertNotNull(tokenAuth); + assertEquals("dummy-token-value", tokenAuth.getToken()); + } + + @Test + public void testProviderExceptionIsIgnoredAndFallsBack() { + FetchCredentials credentials = FetchCredentials.resolveAll(); + FetchAuthData.Token tokenAuth = credentials.get("error-id", FetchAuthData.Token.class); + + assertNull(tokenAuth); + } + + @TestExtension + @SuppressWarnings("unused") + public static class DummyProvider implements FetchCredentialsProvider { + @SuppressWarnings("unchecked") + @Override + public T getCredentials(String credentialId, Class type) { + if ("dummy-id".equals(credentialId) && type == FetchAuthData.Token.class) { + return (T) (FetchAuthData.Token) () -> "dummy-token-value"; + } + return null; + } + } + + @TestExtension + @SuppressWarnings("unused") + public static class ExceptionThrowingProvider implements FetchCredentialsProvider { + @Override + public T getCredentials(String credentialId, Class type) { + if ("error-id".equals(credentialId)) { + throw new RuntimeException("Simulated provider failure"); + } + return null; + } + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsEarlyBootTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsEarlyBootTest.java new file mode 100644 index 0000000000..d4282d02eb --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchCredentialsEarlyBootTest.java @@ -0,0 +1,16 @@ +package io.jenkins.plugins.casc.fetcher; + +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class FetchCredentialsEarlyBootTest { + + @Test + public void testJenkinsNullFallsBackToEnvResolver() { + FetchCredentials credentials = FetchCredentials.resolveAll(); + FetchAuthData.Token tokenAuth = credentials.get("some-id", FetchAuthData.Token.class); + + assertNull(tokenAuth); + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchResultTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchResultTest.java new file mode 100644 index 0000000000..264f8d211c --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/FetchResultTest.java @@ -0,0 +1,81 @@ +package io.jenkins.plugins.casc.fetcher; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; + +public class FetchResultTest { + + @Test + public void testAutoCloseableLifecycle() throws Exception { + AtomicBoolean wasClosed = new AtomicBoolean(false); + AutoCloseable mockResource = () -> wasClosed.set(true); + + FetchResult result = new FetchResult(Collections.emptyList(), mockResource); + + result.close(); + + assertTrue("FetchResult must trigger close() on wrapped AutoCloseables", wasClosed.get()); + } + + @Test + public void testTempDirectoryCleanup() throws Exception { + Path tempDir = Files.createTempDirectory("casc-test-cleanup-"); + Path nestedFile = tempDir.resolve("jenkins.yaml"); + Files.write(nestedFile, "jenkins:".getBytes()); + + assertTrue(Files.exists(nestedFile)); + + FetchResult result = new FetchResult(Collections.emptyList(), tempDir); + + result.close(); + + assertFalse("Nested files must be deleted", Files.exists(nestedFile)); + assertFalse("The temporary directory itself must be deleted", Files.exists(tempDir)); + } + + @Test(expected = IOException.class) + public void testCloseWrapsAndPropagatesExceptions() throws Exception { + AutoCloseable faultyResource = () -> { + throw new Exception("Simulated cleanup failure"); + }; + + FetchResult result = new FetchResult(Collections.emptyList(), faultyResource); + + result.close(); + } + + @Test + public void testCloseIgnoresNullsInLists() throws Exception { + FetchResult result = new FetchResult(Collections.emptyList(), (AutoCloseable) null); + + Field resourcesField = FetchResult.class.getDeclaredField("resourcesToClose"); + resourcesField.setAccessible(true); + @SuppressWarnings("unchecked") + List resources = (List) resourcesField.get(result); + resources.add(null); + + Field pathsField = FetchResult.class.getDeclaredField("pathsToDelete"); + pathsField.setAccessible(true); + @SuppressWarnings("unchecked") + List paths = (List) pathsField.get(result); + paths.add(null); + result.close(); + } + + @Test + public void testCloseSkipsNonExistentDirectory() throws Exception { + Path tempDir = Files.createTempDirectory("casc-missing-"); + FetchResult result = new FetchResult(Collections.emptyList(), tempDir); + Files.delete(tempDir); + result.close(); + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/LocalFileSystemFetcherTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/LocalFileSystemFetcherTest.java new file mode 100644 index 0000000000..9ade0a0cb2 --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/fetcher/LocalFileSystemFetcherTest.java @@ -0,0 +1,109 @@ +package io.jenkins.plugins.casc.fetcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class LocalFileSystemFetcherTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private final LocalFileSystemFetcher fetcher = new LocalFileSystemFetcher(); + + @Test + public void testSupportsLogic() throws IOException { + File tempFile = tempFolder.newFile("test.yaml"); + + assertTrue("Should support valid file:// URI", fetcher.supports("file:///some/path")); + assertTrue("Should support valid local paths", fetcher.supports(tempFile.getAbsolutePath())); + assertFalse("Should reject null locations", fetcher.supports(null)); + assertFalse("Should reject non-existent paths", fetcher.supports("/does/not/exist/ever.yaml")); + } + + @Test + public void testFetchSingleFile() throws Exception { + File file = tempFolder.newFile("single.yaml"); + Files.write(file.toPath(), "jenkins:".getBytes()); + + FetchResult result = fetcher.fetch(file.getAbsolutePath(), null); + List items = result.items(); + + assertEquals(1, items.size()); + assertEquals("single.yaml", items.get(0).relativePath()); + } + + @Test + public void testFetchDirectoryIgnoresHiddenAndNonYaml() throws Exception { + + File dir = tempFolder.newFolder("casc-configs"); + File valid1 = new File(dir, "a.yaml"); + File valid2 = new File(dir, "b.yml"); + File hidden = new File(dir, ".secrets.yaml"); + File txtFile = new File(dir, "readme.txt"); + + Files.write(valid1.toPath(), "jenkins:".getBytes()); + Files.write(valid2.toPath(), "unclassified:".getBytes()); + Files.write(hidden.toPath(), "secret: 123".getBytes()); + Files.write(txtFile.toPath(), "hello".getBytes()); + + FetchResult result = fetcher.fetch(dir.getAbsolutePath(), null); + List items = result.items(); + + assertEquals("Should only find a.yaml and b.yml", 2, items.size()); + + boolean hasA = items.stream().anyMatch(i -> i.relativePath().equals("a.yaml")); + boolean hasB = items.stream().anyMatch(i -> i.relativePath().equals("b.yml")); + + assertTrue(hasA); + assertTrue(hasB); + } + + @Test + public void testNestedDirectoryTraversal() throws Exception { + File rootDir = tempFolder.newFolder("configs"); + File prodDir = new File(rootDir, "prod"); + assertTrue("Failed to create nested directory", prodDir.mkdir()); + + File jenkinsYaml = new File(prodDir, "jenkins.yaml"); + Files.write(jenkinsYaml.toPath(), "jenkins:\n mode: EXCLUSIVE".getBytes()); + + FetchResult result = fetcher.fetch(rootDir.getAbsolutePath(), null); + List items = result.items(); + + assertEquals(1, items.size()); + assertEquals("prod/jenkins.yaml", items.get(0).relativePath()); + } + + @Test + public void testResolvedYamlToString() { + ResolvedYaml yaml = new ResolvedYaml("my/test/config.yaml", () -> null); + + assertEquals("ResolvedYaml{relativePath='my/test/config.yaml'}", yaml.toString()); + } + + @Test + public void testSupportsThrowsExceptionOnInvalidCharacters() { + assertFalse( + "Should safely catch exception and return false for strictly invalid path strings", + fetcher.supports("invalid\u0000path")); + } + + @Test(expected = IOException.class) + public void testFetchThrowsOnInvalidFileUri() throws Exception { + fetcher.fetch("file:///invalid path with spaces.yaml", null); + } + + @Test(expected = IOException.class) + public void testFetchThrowsOnNonExistentPath() throws Exception { + fetcher.fetch("/this/path/absolutely/does/not/exist/casc.yaml", null); + } +} diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/yaml/YamlSourceTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/yaml/YamlSourceTest.java index b03c7420fc..22e7a140e0 100644 --- a/plugin/src/test/java/io/jenkins/plugins/casc/yaml/YamlSourceTest.java +++ b/plugin/src/test/java/io/jenkins/plugins/casc/yaml/YamlSourceTest.java @@ -48,4 +48,18 @@ void shouldHaveInformativeToStringForRequestSource() { YamlSource yamlSource = YamlSource.of(request); assertEquals("YamlSource: /configuration-as-code/check", yamlSource.toString()); } + + @Test + void shouldHandleInputStreamWithName() { + InputStream testInputStream = new ByteArrayInputStream("IS content".getBytes(StandardCharsets.UTF_8)); + YamlSource yamlSource = YamlSource.of(testInputStream, "my-custom-stream"); + assertEquals("YamlSource: my-custom-stream", yamlSource.toString()); + } + + @Test + void shouldHandlePathWithName() { + Path path = new File("./test").toPath(); + YamlSource yamlSource = YamlSource.of(path, "my-custom-path"); + assertEquals("YamlSource: my-custom-path", yamlSource.toString()); + } } diff --git a/test-harness/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeTest.java b/test-harness/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeTest.java index 90364a27e2..8f89a9c682 100644 --- a/test-harness/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeTest.java +++ b/test-harness/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeTest.java @@ -11,6 +11,8 @@ import static org.hamcrest.Matchers.is; import static org.htmlunit.HttpMethod.POST; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -18,6 +20,9 @@ import hudson.Functions; import hudson.util.FormValidation; +import io.jenkins.plugins.casc.fetcher.CasCConfigFetcher; +import io.jenkins.plugins.casc.fetcher.FetchCredentials; +import io.jenkins.plugins.casc.fetcher.FetchResult; import io.jenkins.plugins.casc.misc.ConfiguredWithCode; import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule; import io.jenkins.plugins.casc.misc.junit.jupiter.WithJenkinsConfiguredWithCode; @@ -27,6 +32,8 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -45,6 +52,7 @@ import org.junit.jupiter.api.io.TempDir; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule.WebClient; +import org.jvnet.hudson.test.TestExtension; @WithJenkinsConfiguredWithCode class ConfigurationAsCodeTest { @@ -146,7 +154,7 @@ void test_loads_multi_files(JenkinsConfiguredWithCodeRule j) { } @Test - void shouldReportMissingFileOnNotFoundConfig() { + void shouldReportMissingFileOnNotFoundConfig(JenkinsConfiguredWithCodeRule j) { ConfigurationAsCode casc = new ConfigurationAsCode(); assertThrows(ConfiguratorException.class, () -> casc.configure("some")); } @@ -291,7 +299,7 @@ void test_non_first_yaml_file_empty(JenkinsConfiguredWithCodeRule j) { @Test @Issue("Issue #914") - void isSupportedURI_should_not_throw_on_invalid_uri() { + void isSupportedURI_should_not_throw_on_invalid_uri(JenkinsConfiguredWithCodeRule j) { // for example, a Windows path is not a valid URI assertThat(ConfigurationAsCode.isSupportedURI("C:\\jenkins\\casc"), is(false)); } @@ -340,6 +348,119 @@ void testHtmlDocStringRetrieval(JenkinsConfiguredWithCodeRule j) throws Exceptio assertEquals(expectedDocString, actualDocString); } + @Test + void doCheckNewSource_should_catch_exceptions_on_invalid_yaml(JenkinsConfiguredWithCodeRule j) throws Exception { + File brokenYaml = newFile(tempFolder, "broken.yaml"); + Files.write(brokenYaml.toPath(), "jenkins: \n\t- tabs: are: forbidden: in: yaml".getBytes()); + + ConfigurationAsCode casc = ConfigurationAsCode.get(); + + FormValidation validation = casc.doCheckNewSource(brokenYaml.getAbsolutePath()); + + assertEquals(FormValidation.Kind.ERROR, validation.kind); + } + + @Test + void doCheckNewSource_should_return_error_for_invalid_config(JenkinsConfiguredWithCodeRule j) throws Exception { + File invalidConfig = newFile(tempFolder, "invalid.yaml"); + Files.write(invalidConfig.toPath(), "jenkins:\n definitelyNotARealProperty: true".getBytes()); + + ConfigurationAsCode casc = ConfigurationAsCode.get(); + + FormValidation validation = casc.doCheckNewSource(invalidConfig.getAbsolutePath()); + + assertEquals(FormValidation.Kind.ERROR, validation.kind); + assertTrue( + validation.getMessage().contains("definitelyNotARealProperty"), + "Error message should mention the invalid property"); + } + + @Test + void configure_should_wrap_ioexception_from_fetcher(JenkinsConfiguredWithCodeRule j) { + ConfigurationAsCode casc = ConfigurationAsCode.get(); + + ConfiguratorException ex = assertThrows( + ConfiguratorException.class, + () -> casc.configure("file:/this/path/definitely/does/not/exist_casc.yaml")); + + assertTrue( + ex.getMessage().contains("Failed to fetch configuration from"), + "Exception message should come from the IOException catch block"); + } + + @Test + void configure_should_throw_when_source_is_not_supported(JenkinsConfiguredWithCodeRule j) { + ConfigurationAsCode casc = ConfigurationAsCode.get(); + + ConfiguratorException ex = + assertThrows(ConfiguratorException.class, () -> casc.configure("unsupported://protocol-scheme-test")); + + assertTrue( + ex.getMessage().contains("is not supported by any registered configuration fetcher"), + "Exception message should indicate unsupported source protocol"); + } + + @Test + void isSupportedURI_should_cover_all_branches(JenkinsConfiguredWithCodeRule j) { + assertFalse(ConfigurationAsCode.isSupportedURI(null), "Should return false for null input"); + + assertTrue( + ConfigurationAsCode.isSupportedURI("throw-io://test"), + "Should return true when our test fetcher claims support"); + + assertFalse( + ConfigurationAsCode.isSupportedURI("unsupported-scheme://test"), + "Should return false when no fetchers support the scheme"); + } + + @Test + void getConfigFromSources_should_throw_on_ioexception(JenkinsConfiguredWithCodeRule j) throws Exception { + ConfigurationAsCode casc = ConfigurationAsCode.get(); + + Method method = ConfigurationAsCode.class.getDeclaredMethod("getConfigFromSources", List.class); + method.setAccessible(true); + + InvocationTargetException ex = + assertThrows(InvocationTargetException.class, () -> method.invoke(casc, List.of("throw-io://test"))); + + assertInstanceOf(ConfiguratorException.class, ex.getCause(), "Cause should be ConfiguratorException"); + assertTrue( + ex.getCause().getMessage().contains("Failed to fetch configuration from"), + "Should cover the catch (IOException e) block"); + } + + @Test + void getConfigFromSources_should_throw_on_unsupported_source(JenkinsConfiguredWithCodeRule j) throws Exception { + ConfigurationAsCode casc = ConfigurationAsCode.get(); + + Method method = ConfigurationAsCode.class.getDeclaredMethod("getConfigFromSources", List.class); + method.setAccessible(true); + + InvocationTargetException ex = assertThrows( + InvocationTargetException.class, + () -> method.invoke(casc, List.of("definitely-unsupported-scheme://test"))); + + assertInstanceOf(ConfiguratorException.class, ex.getCause(), "Cause should be ConfiguratorException"); + assertTrue( + ex.getCause().getMessage().contains("is not supported by any registered configuration fetcher"), + "Should cover the if (!fetched) block"); + } + + @TestExtension + @SuppressWarnings("unused") + public static class ThrowOnFetchFetcher implements CasCConfigFetcher { + + @Override + public boolean supports(String location) { + return "throw-io://test".equals(location); + } + + @Override + public FetchResult fetch(String location, FetchCredentials credentials) throws IOException { + throw new IOException("Simulated fetch failure"); + } + } + private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder);