/*- * ============LICENSE_START======================================================= * feature-state-management * ================================================================================ * Copyright (C) 2017-2021 AT&T Intellectual Property. All rights reserved. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * ============LICENSE_END========================================================= */ package org.onap.policy.drools.statemanagement; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Path; import java.util.LinkedList; import java.util.List; import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import lombok.AllArgsConstructor; import lombok.Getter; import org.apache.commons.io.FileUtils; import org.onap.policy.common.im.IntegrityMonitorException; import org.onap.policy.common.utils.resources.DirectoryUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * This class audits the Maven repository. */ public class RepositoryAudit extends DroolsPdpIntegrityMonitor.AuditBase { // timeout in 60 seconds private static final long DEFAULT_TIMEOUT = 60; // get an instance of logger private static final Logger logger = LoggerFactory.getLogger(RepositoryAudit.class); // single global instance of this audit object @Getter private static RepositoryAudit instance = new RepositoryAudit(); // Regex pattern used to find additional repos in the form "repository(number).id.url" private static final Pattern repoPattern = Pattern.compile("(repository([1-9][0-9]*))[.]audit[.]id"); /** * Constructor - set the name to 'Repository'. */ private RepositoryAudit() { super("Repository"); } /** * First, get the names of each property from StateManagementProperties. For each property name, check if it is of * the form "repository(number).audit.id" If so, we extract the number and determine if there exists another * property in the form "repository(number).audit.url" with the same "number". Only the * 'repository(number).audit.id' and 'repository(number).audit.url" properties need to be specified. If both 'id' * and 'url' properties are found, we add it to our set. InvokeData.getProperty(String, boolean) will determine the * other 4 properties: '*.username', '*.password', '*.is.active', and '*.ignore.errors', or use default values. * * @return set of Integers representing a repository to support */ private static TreeSet countAdditionalNexusRepos() { TreeSet returnIndices = new TreeSet<>(); var properties = StateManagementProperties.getProperties(); Set propertyNames = properties.stringPropertyNames(); for (String currName : propertyNames) { var matcher = repoPattern.matcher(currName); if (matcher.matches()) { var currRepoNum = Integer.parseInt(matcher.group(2)); if (propertyNames.contains(matcher.group(1) + ".audit.url")) { returnIndices.add(currRepoNum); } } } return returnIndices; } /** * Invoke the audit. * * @param properties properties to be passed to the audit */ @Override public void invoke(Properties properties) throws IntegrityMonitorException { logger.debug("Running 'RepositoryAudit.invoke'"); var data = new InvokeData(); logger.debug("RepositoryAudit.invoke: repoAuditIsActive = {}" + ", repoAuditIgnoreErrors = {}", data.repoAuditIsActive, data.repoAuditIgnoreErrors); data.initIsActive(); if (!data.isActive) { logger.info("RepositoryAudit.invoke: exiting because isActive = {}", data.isActive); return; } try { // Run audit for first nexus repository logger.debug("Running read-only audit on first nexus repository: repository"); runAudit(data); // set of indices for supported nexus repos (ex: repository2 -> 2) // TreeSet is used to maintain order so repos can be audited in numerical // order TreeSet repoIndices = countAdditionalNexusRepos(); logger.debug("Additional nexus repositories: {}", repoIndices); // Run audit for remaining 'numNexusRepos' repositories for (int index : repoIndices) { logger.debug("Running read-only audit on nexus repository = repository{}", index); data = new InvokeData(index); data.initIsActive(); if (data.isActive) { runAudit(data); } } } catch (IOException e) { throw new IntegrityMonitorException(e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IntegrityMonitorException(e); } } private void runAudit(InvokeData data) throws IOException, InterruptedException { data.initIgnoreErrors(); data.initTimeout(); /* * 1) create temporary directory */ data.dir = DirectoryUtils.createTempDirectory("auditRepo"); // nested 'pom.xml' file and 'repo' directory final Path pom = data.dir.resolve("pom.xml"); final Path repo = data.dir.resolve("repo"); /* * 2) Create test file, and upload to repository (only if repository information is specified) */ if (data.upload) { data.uploadTestFile(); } /* * 3) create 'pom.xml' file in temporary directory */ data.createPomFile(repo, pom); /* * 4) Invoke external 'mvn' process to do the downloads */ // output file = ${dir}/out (this supports step '4a') var output = data.dir.resolve("out").toFile(); // invoke process, and wait for response int rval = data.runMaven(output); /* * 4a) Check attempted and successful downloads from output file Note: at present, this step just generates log * messages, but doesn't do any verification. */ if (rval == 0 && output != null) { generateDownloadLogs(output); } /* * 5) Check the contents of the directory to make sure the downloads were successful */ data.verifyDownloads(repo); /* * 6) Use 'curl' to delete the uploaded test file (only if repository information is specified) */ if (data.upload) { data.deleteUploadedTestFile(); } /* * 7) Remove the temporary directory */ FileUtils.forceDelete(data.dir.toFile()); } /** * Set the response string to the specified value. Overrides 'setResponse(String value)' from * DroolsPdpIntegrityMonitor This method prevents setting a response string that indicates whether the caller should * receive an error list from the audit. By NOT setting the response string to a value, this indicates that there * are no errors. * * @param value the new value of the response string (null = no errors) */ @Override public void setResponse(String value) { // Do nothing, prevent the caller from receiving a list of errors. } private class InvokeData { private boolean isActive = true; // ignore errors by default private boolean ignoreErrors = true; private final String repoAuditIsActive; private final String repoAuditIgnoreErrors; private final String repositoryId; private final String repositoryUrl; private final String repositoryUsername; private final String repositoryPassword; private final boolean upload; // used to incrementally construct response as problems occur // (empty = no problems) private final StringBuilder response = new StringBuilder(); private long timeoutInSeconds = DEFAULT_TIMEOUT; private Path dir; private String groupId = null; private String artifactId = null; private String version = null; // artifacts to be downloaded private final List artifacts = new LinkedList<>(); // 0 = base repository, 2-n = additional repositories private final int index; public InvokeData() { this(0); } public InvokeData(int index) { this.index = index; repoAuditIsActive = getProperty("audit.is.active", true); repoAuditIgnoreErrors = getProperty("audit.ignore.errors", true); // Fetch repository information from 'IntegrityMonitorProperties' repositoryId = getProperty("audit.id", false); repositoryUrl = getProperty("audit.url", false); repositoryUsername = getProperty("audit.username", true); repositoryPassword = getProperty("audit.password", true); logger.debug("Nexus Repository Information retrieved from 'IntegrityMonitorProperties':"); logger.debug("repositoryId: {}", repositoryId); logger.debug("repositoryUrl: {}", repositoryUrl); // Setting upload to be false so that files can no longer be created/deleted upload = false; } private String getProperty(String property, boolean useDefault) { String fullProperty = (index == 0 ? "repository." + property : "repository" + index + "." + property); String rval = StateManagementProperties.getProperty(fullProperty); if (rval == null && index != 0 && useDefault) { rval = StateManagementProperties.getProperty("repository." + property); } return rval; } public void initIsActive() { if (repoAuditIsActive != null) { try { isActive = Boolean.parseBoolean(repoAuditIsActive.trim()); } catch (NumberFormatException e) { logger.warn("RepositoryAudit.invoke: Ignoring invalid property: repository.audit.is.active = {}", repoAuditIsActive); } } if (repositoryId == null || repositoryUrl == null) { isActive = false; } } public void initIgnoreErrors() { if (repoAuditIgnoreErrors != null) { try { ignoreErrors = Boolean.parseBoolean(repoAuditIgnoreErrors.trim()); } catch (NumberFormatException e) { ignoreErrors = true; logger.warn( "RepositoryAudit.invoke: Ignoring invalid property: repository.audit.ignore.errors = {}", repoAuditIgnoreErrors); } } else { ignoreErrors = true; } } public void initTimeout() { var timeoutString = getProperty("audit.timeout", true); if (timeoutString != null && !timeoutString.isEmpty()) { try { timeoutInSeconds = Long.valueOf(timeoutString); } catch (NumberFormatException e) { logger.error("RepositoryAudit: Invalid 'repository.audit.timeout' value: '{}'", timeoutString, e); if (!ignoreErrors) { response.append("Invalid 'repository.audit.timeout' value: '").append(timeoutString) .append("'\n"); setResponse(response.toString()); } } } } private void uploadTestFile() throws IOException, InterruptedException { groupId = "org.onap.policy.audit"; artifactId = "repository-audit"; version = "0." + System.currentTimeMillis(); if (repositoryUrl.toLowerCase().contains("snapshot")) { // use SNAPSHOT version version += "-SNAPSHOT"; } // create text file to write try (var fos = new FileOutputStream(dir.resolve("repository-audit.txt").toFile())) { fos.write(version.getBytes()); } // try to install file in repository if (runProcess(timeoutInSeconds, dir.toFile(), null, "mvn", "deploy:deploy-file", "-DrepositoryId=" + repositoryId, "-Durl=" + repositoryUrl, "-Dfile=repository-audit.txt", "-DgroupId=" + groupId, "-DartifactId=" + artifactId, "-Dversion=" + version, "-Dpackaging=txt", "-DgeneratePom=false") != 0) { logger.error("RepositoryAudit: 'mvn deploy:deploy-file' failed"); if (!ignoreErrors) { response.append("'mvn deploy:deploy-file' failed\n"); setResponse(response.toString()); } } else { logger.info("RepositoryAudit: 'mvn deploy:deploy-file succeeded"); // we also want to include this new artifact in the download // test (steps 3 and 4) artifacts.add(new Artifact(groupId, artifactId, version, "txt")); } } private void createPomFile(final Path repo, final Path pom) throws IOException { artifacts.add(new Artifact("org.apache.maven/maven-embedder/3.2.2")); var sb = new StringBuilder(); sb.append( "\n" + "\n" + " 4.0.0\n" + " empty\n" + " empty\n" + " 1.0-SNAPSHOT\n" + " pom\n" + "\n" + " \n" + " \n" + " \n" + " org.apache.maven.plugins\n" + " maven-dependency-plugin\n" + " 2.10\n" + " \n" + " \n" + " copy\n" + " \n" + " copy\n" + " \n" + " \n" + " ") .append(repo).append("\n").append(" \n"); for (Artifact artifact : artifacts) { // each artifact results in an 'artifactItem' element sb.append(" \n" + " ").append(artifact.groupId) .append("\n" + " ").append(artifact.artifactId) .append("\n" + " ").append(artifact.version) .append("\n" + " ").append(artifact.type) .append("\n" + " \n"); } sb.append(" \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"); try (var fos = new FileOutputStream(pom.toFile())) { fos.write(sb.toString().getBytes()); } } private int runMaven(File output) throws IOException, InterruptedException { int rval = runProcess(timeoutInSeconds, dir.toFile(), output, "mvn", "compile"); logger.info("RepositoryAudit: 'mvn' return value = {}", rval); if (rval != 0) { logger.error("RepositoryAudit: 'mvn compile' invocation failed"); if (!ignoreErrors) { response.append("'mvn compile' invocation failed\n"); setResponse(response.toString()); } } return rval; } private void verifyDownloads(final Path repo) { for (Artifact artifact : artifacts) { if (repo.resolve(artifact.groupId.replace('.', '/')).resolve(artifact.artifactId) .resolve(artifact.version) .resolve(artifact.artifactId + "-" + artifact.version + "." + artifact.type).toFile() .exists()) { // artifact exists, as expected logger.info("RepositoryAudit: {} : exists", artifact); } else { // Audit ERROR: artifact download failed for some reason logger.error("RepositoryAudit: {}: does not exist", artifact); if (!ignoreErrors) { response.append("Failed to download artifact: ").append(artifact).append('\n'); setResponse(response.toString()); } } } } private void deleteUploadedTestFile() throws IOException, InterruptedException { if (runProcess(timeoutInSeconds, dir.toFile(), null, "curl", "--request", "DELETE", "--user", repositoryUsername + ":" + repositoryPassword, repositoryUrl + "/" + groupId.replace('.', '/') + "/" + artifactId + "/" + version) != 0) { logger.error("RepositoryAudit: delete of uploaded artifact failed"); if (!ignoreErrors) { response.append("delete of uploaded artifact failed\n"); setResponse(response.toString()); } } else { logger.info("RepositoryAudit: delete of uploaded artifact succeeded"); artifacts.add(new Artifact(groupId, artifactId, version, "txt")); } } } private void generateDownloadLogs(File output) throws IOException { // place output in 'fileContents' (replacing the Return characters // with Newline) var outputData = new byte[(int) output.length()]; String fileContents; try (var fis = new FileInputStream(output)) { // // Ideally this should be in a loop or even better use // Java 8 nio functionality. // int bytesRead = fis.read(outputData); logger.info("fileContents read {} bytes", bytesRead); fileContents = new String(outputData).replace('\r', '\n'); } // generate log messages from 'Downloading' and 'Downloaded' // messages within the 'mvn' output var index = 0; while ((index = fileContents.indexOf("\nDown", index)) > 0) { index += 5; if (fileContents.regionMatches(index, "loading: ", 0, 9)) { index += 9; int endIndex = fileContents.indexOf('\n', index); if (logger.isInfoEnabled()) { logger.info("RepositoryAudit: Attempted download: '{}'", fileContents.substring(index, endIndex)); } index = endIndex; } else if (fileContents.regionMatches(index, "loaded: ", 0, 8)) { index += 8; int endIndex = fileContents.indexOf(' ', index); if (logger.isInfoEnabled()) { logger.info("RepositoryAudit: Successful download: '{}'", fileContents.substring(index, endIndex)); } index = endIndex; } } } /** * Run a process, and wait for the response. * * @param timeoutInSeconds the number of seconds to wait for the process to terminate * @param directory the execution directory of the process (null = current directory) * @param stdout the file to contain the standard output (null = discard standard output) * @param command command and arguments * @return the return value of the process * @throws IOException InterruptedException */ static int runProcess(long timeoutInSeconds, File directory, File stdout, String... command) throws IOException, InterruptedException { var pb = new ProcessBuilder(command); if (directory != null) { pb.directory(directory); } if (stdout != null) { pb.redirectOutput(stdout); } var process = pb.start(); if (process.waitFor(timeoutInSeconds, TimeUnit.SECONDS)) { // process terminated before the timeout return process.exitValue(); } // process timed out -- kill it, and return -1 process.destroyForcibly(); return -1; } /* ============================================================ */ /** * An instance of this class exists for each artifact that we are trying to download. */ @AllArgsConstructor static class Artifact { String groupId; String artifactId; String version; String type; /** * Constructor - populate an 'Artifact' instance. * * @param artifact a string of the form: {@code"//[/]"} * @throws IllegalArgumentException if 'artifact' has the incorrect format */ Artifact(String artifact) { String[] segments = artifact.split("/"); if (segments.length != 4 && segments.length != 3) { throw new IllegalArgumentException("groupId/artifactId/version/type"); } groupId = segments[0]; artifactId = segments[1]; version = segments[2]; type = segments.length == 4 ? segments[3] : "jar"; } /** * Returns string representation. * * @return the artifact id in the form: {@code"///"} */ @Override public String toString() { return groupId + "/" + artifactId + "/" + version + "/" + type; } } }