blob: 620fa6493212cba4917da26addcbd2ee689a6cf9 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional information regarding
* copyright ownership. The ASF licenses this file to You 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.
*/
package org.apache.geode.internal;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.logging.log4j.Logger;
import org.apache.geode.annotations.VisibleForTesting;
import org.apache.geode.annotations.internal.MakeNotStatic;
import org.apache.geode.logging.internal.log4j.api.LogService;
public class JarDeployer implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger logger = LogService.getLogger();
// The pound version scheme predates the sequenced version scheme
private static final Pattern POUND_VERSION_SCHEME =
Pattern.compile("^vf\\.gf#(?<artifact>.*)\\.jar#(?<version>\\d+)$");
// Every deployed file will use this scheme to signify the sequence it's been deployed
static final Pattern DEPLOYED_FILE_PATTERN =
Pattern.compile("(?<baseName>..*)\\.v(?<version>\\d++).jar$");
// we can recognize jar files with below pattern. If two jar files have the same artifact, then
// the latter will replace the former deployed jar
private static final Pattern USER_VERSION_PATTERN =
Pattern.compile("(?<artifact>.*?)[-.]\\d+.*\\.jar$");
@MakeNotStatic
private static final Lock lock = new ReentrantLock();
private final Map<String, DeployedJar> deployedJars = new ConcurrentHashMap<>();
private final File deployDirectory;
public JarDeployer() {
this(new File(System.getProperty("user.dir")));
}
public JarDeployer(final File deployDirectory) {
this.deployDirectory = deployDirectory;
}
/**
* Writes the jarBytes for the given jarName to the next version of that jar file (if the bytes do
* not match the latest deployed version).
*
* @return the DeployedJar that was written from jarBytes, or null if those bytes matched the
* latest deployed version
*/
public DeployedJar deployWithoutRegistering(final File stagedJar)
throws IOException {
lock.lock();
String stagedJarName = stagedJar.getName();
String artifactId = getArtifactId(stagedJarName);
try {
boolean shouldDeployNewVersion = shouldDeployNewVersion(artifactId, stagedJar);
if (!shouldDeployNewVersion) {
logger.debug("No need to deploy a new version of {}", stagedJarName);
return null;
}
verifyWritableDeployDirectory();
Path deployedFile = getNextVersionedJarFile(stagedJarName).toPath();
FileUtils.copyFile(stagedJar, deployedFile.toFile());
return new DeployedJar(deployedFile.toFile());
} finally {
lock.unlock();
}
}
/**
* Get a list of all currently deployed jars.
*
* @return The list of DeployedJars
*/
public List<DeployedJar> findDeployedJars() {
return getDeployedJars().values().stream().collect(toList());
}
/**
* Suspend all deploy and undeploy operations. This is done by acquiring and holding the lock
* needed in order to perform a deploy or undeploy and so it will cause all threads attempting to
* do one of these to block. This makes it somewhat of a time sensitive call as forcing these
* other threads to block for an extended period of time may cause other unforeseen problems. It
* must be followed by a call to {@link #resumeAll()}.
*/
public void suspendAll() {
lock.lock();
}
/**
* Release the lock that controls entry into the deploy/undeploy methods which will allow those
* activities to continue.
*/
public void resumeAll() {
lock.unlock();
}
protected File getNextVersionedJarFile(String unversionedJarName) {
int maxVersion = getMaxVersion(getArtifactId(unversionedJarName));
String nextVersionJarName =
FilenameUtils.getBaseName(unversionedJarName) + ".v" + (maxVersion + 1) + ".jar";
logger.debug("Next versioned jar name for {} is {}", unversionedJarName, nextVersionJarName);
return new File(deployDirectory, nextVersionJarName);
}
protected int getMaxVersion(String artifactId) {
return Arrays.stream(deployDirectory.list()).filter(x -> artifactId.equals(toArtifactId(x)))
.map(JarDeployer::extractVersionFromFilename)
.reduce(Integer::max).orElse(0);
}
/**
* Find the version number that's embedded in the name of this file
*
* @param filename Filename to get the version number from
* @return The version number embedded in the filename
*/
public static int extractVersionFromFilename(final String filename) {
final Matcher matcher = DEPLOYED_FILE_PATTERN.matcher(filename);
if (matcher.find()) {
return Integer.parseInt(matcher.group(2));
} else {
return 0;
}
}
public static boolean isDeployedFile(String filename) {
return DEPLOYED_FILE_PATTERN.matcher(filename).find();
}
public static boolean isSemanticVersion(String filename) {
return USER_VERSION_PATTERN.matcher(filename).find();
}
/**
* get the artifact id from the existing files on the server. This will skip files that
* do not have sequence id appended to them.
*
* @param sequencedJarFileName the file names that exists on the server, it should always ends
* with a sequence number
* @return the artifact id. if a file with no sequence number is passed in, this will return null
*/
static String toArtifactId(String sequencedJarFileName) {
String baseName = getDeployedFileBaseName(sequencedJarFileName);
if (baseName == null) {
return null;
}
return getArtifactId(baseName + ".jar");
}
/**
* get the artifact id from the files deployed by the user. This will recognize files with
* SEMANTIC_VERSION_PATTERN, it will strip off the version part from the filename. For all other
* file names, it will just return the basename.
*
* @param deployedJarFileName the filename that's deployed by the user. could be in the form of
* abc.jar or abc-1.0.0.jar, both should return abc
* @return the artifact id of the string
*/
public static String getArtifactId(String deployedJarFileName) {
Matcher semanticVersionMatcher = USER_VERSION_PATTERN.matcher(deployedJarFileName);
if (semanticVersionMatcher.matches()) {
return semanticVersionMatcher.group("artifact");
} else {
return FilenameUtils.getBaseName(deployedJarFileName);
}
}
public static String getDeployedFileBaseName(String sequencedJarFileName) {
Matcher semanticVersionMatcher = DEPLOYED_FILE_PATTERN.matcher(sequencedJarFileName);
if (semanticVersionMatcher.matches()) {
return semanticVersionMatcher.group("baseName");
} else {
return null;
}
}
/**
* Make sure that the deploy directory is writable.
*
* @throws IOException If the directory isn't writable
*/
public void verifyWritableDeployDirectory() throws IOException {
try {
if (this.deployDirectory.canWrite()) {
return;
}
} catch (SecurityException ex) {
throw new IOException("Unable to write to deploy directory", ex);
}
throw new IOException(
"Unable to write to deploy directory: " + this.deployDirectory.getCanonicalPath());
}
/*
* In Geode 1.1.0, the deployed version of 'myjar.jar' would be named 'vf.gf#myjar.jar#1'. Now it
* is be named 'myjar.v1.jar'. We need to rename all existing deployed jars to the new convention
* if this is the first time starting up with the new naming format.
*/
protected void renameJarsWithOldNamingConvention() throws IOException {
Set<File> jarsWithOldNamingConvention = findJarsWithOldNamingConvention();
if (jarsWithOldNamingConvention.isEmpty()) {
return;
}
for (File jar : jarsWithOldNamingConvention) {
renameJarWithOldNamingConvention(jar);
}
}
protected Set<File> findJarsWithOldNamingConvention() {
return Stream.of(this.deployDirectory.listFiles())
.filter((File file) -> isOldNamingConvention(file.getName())).collect(toSet());
}
protected boolean isOldNamingConvention(String fileName) {
return POUND_VERSION_SCHEME.matcher(fileName).matches();
}
private void renameJarWithOldNamingConvention(File oldJar) throws IOException {
Matcher matcher = POUND_VERSION_SCHEME.matcher(oldJar.getName());
if (!matcher.matches()) {
throw new IllegalArgumentException("The given jar " + oldJar.getCanonicalPath()
+ " does not match the old naming convention");
}
String unversionedJarNameWithoutExtension = matcher.group(1);
String jarVersion = matcher.group(2);
String newJarName = unversionedJarNameWithoutExtension + ".v" + jarVersion + ".jar";
File newJar = new File(this.deployDirectory, newJarName);
logger.debug("Renaming deployed jar from {} to {}", oldJar.getCanonicalPath(),
newJar.getCanonicalPath());
FileUtils.moveFile(oldJar, newJar);
}
/**
* Re-deploy all previously deployed JAR files on disk.
* It will clean up the old version of deployed jars that are in the deployed directory
*/
public void loadPreviouslyDeployedJarsFromDisk() {
logger.info("Loading previously deployed jars");
lock.lock();
try {
verifyWritableDeployDirectory();
renameJarsWithOldNamingConvention();
// find all the artifacts and its max versions
Map<String, Integer> artifactToMaxVersion = findArtifactsAndMaxVersion();
List<DeployedJar> latestVersionOfEachJar = new ArrayList<>();
// clean up the old versions and find the latest version of each jar
for (File file : deployDirectory.listFiles()) {
String artifactId = toArtifactId(file.getName());
if (artifactId == null) {
continue;
}
int version = extractVersionFromFilename(file.getName());
if (version < artifactToMaxVersion.get(artifactId)) {
FileUtils.deleteQuietly(file);
} else {
latestVersionOfEachJar.add(new DeployedJar(file));
}
}
registerNewVersions(latestVersionOfEachJar);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
Map<String, Integer> findArtifactsAndMaxVersion() {
Map<String, Integer> artifactToMaxVersion = new HashMap<>();
for (String fileName : deployDirectory.list()) {
String artifactId = toArtifactId(fileName);
if (artifactId == null) {
continue;
}
int version = extractVersionFromFilename(fileName);
Integer maxVersion = artifactToMaxVersion.get(artifactId);
if (maxVersion == null || maxVersion < version) {
artifactToMaxVersion.put(artifactId, version);
}
}
return artifactToMaxVersion;
}
public List<DeployedJar> registerNewVersions(List<DeployedJar> deployedJars)
throws ClassNotFoundException {
lock.lock();
try {
Map<DeployedJar, DeployedJar> newVersionToOldVersion = new HashMap<>();
for (DeployedJar deployedJar : deployedJars) {
if (deployedJar != null) {
logger.info("Registering new version of jar: {}", deployedJar);
DeployedJar oldJar = this.deployedJars.put(deployedJar.getArtifactId(), deployedJar);
ClassPathLoader.getLatest().chainClassloader(deployedJar);
newVersionToOldVersion.put(deployedJar, oldJar);
}
}
// Finally, unregister functions that were removed
for (Map.Entry<DeployedJar, DeployedJar> entry : newVersionToOldVersion.entrySet()) {
DeployedJar newjar = entry.getKey();
DeployedJar oldJar = entry.getValue();
newjar.registerFunctions();
if (oldJar != null) {
oldJar.cleanUp(newjar);
}
}
} finally {
lock.unlock();
}
return deployedJars;
}
/**
* Deploy the given JAR files.
*
* When deploying a jar file, it will always append a sequence number .v<digit> to the end of
* the file, no matter how the original file is named. This is to allow server on startup to
* know what's the last version that gets deployed without cluster configuration.
*
* @param stagedJarFiles A map of Files which have been staged in another location and are ready
* to be deployed as a unit.
* @return An array of newly created JAR class loaders. Entries will be null for an JARs that were
* already deployed.
* @throws IOException When there's an error saving the JAR file to disk
*/
public List<DeployedJar> deploy(final Set<File> stagedJarFiles)
throws IOException, ClassNotFoundException {
List<DeployedJar> deployedJars = new ArrayList<>(stagedJarFiles.size());
for (File jar : stagedJarFiles) {
if (!DeployedJar.hasValidJarContent(jar)) {
throw new IllegalArgumentException(
"File does not contain valid JAR content: " + jar.getName());
}
}
lock.lock();
try {
for (File stagedJarFile : stagedJarFiles) {
deployedJars.add(deployWithoutRegistering(stagedJarFile));
}
return registerNewVersions(deployedJars);
} finally {
lock.unlock();
}
}
private boolean shouldDeployNewVersion(String artifactId, File stagedJar) throws IOException {
DeployedJar oldDeployedJar = this.deployedJars.get(artifactId);
if (oldDeployedJar == null) {
return true;
}
if (oldDeployedJar.hasSameContentAs(stagedJar)) {
logger.warn("Jar is identical to the latest deployed version: {}",
oldDeployedJar.getFileCanonicalPath());
return false;
}
return true;
}
/**
* Returns the latest registered {@link DeployedJar} for the given JarName
*
* @param jarName - the unversioned jar name, e.g. myJar.jar
*/
@VisibleForTesting
public DeployedJar getDeployedJar(String jarName) {
return this.deployedJars.get(getArtifactId(jarName));
}
@VisibleForTesting
public DeployedJar deploy(final File stagedJarFile)
throws IOException, ClassNotFoundException {
lock.lock();
Set<File> jarFiles = new HashSet();
jarFiles.add(stagedJarFile);
try {
List<DeployedJar> deployedJars = deploy(jarFiles);
if (deployedJars == null || deployedJars.size() == 0) {
return null;
}
return deployedJars.get(0);
} finally {
lock.unlock();
}
}
public Map<String, DeployedJar> getDeployedJars() {
return Collections.unmodifiableMap(this.deployedJars);
}
/**
* Undeploy the jar file identified by the given artifact ID.
*
* @param jarName The jarFile to undeploy
* @return The path to the location on disk where the JAR file had been deployed
* @throws IOException If there's a problem deleting the file
*/
public String undeploy(final String jarName) throws IOException {
String artifactId = getArtifactId(jarName);
lock.lock();
try {
DeployedJar deployedJar = deployedJars.get(artifactId);
if (deployedJar == null) {
throw new IllegalArgumentException(jarName + " not deployed");
}
if (!deployedJar.getDeployedFileName().equals(jarName)) {
throw new IllegalArgumentException(jarName + " not deployed");
}
// remove the deployedJar
deployedJars.remove(artifactId);
ClassPathLoader.getLatest().unloadClassloaderForArtifact(artifactId);
deployedJar.cleanUp(null);
deleteAllVersionsOfJar(jarName);
return deployedJar.getFileCanonicalPath();
} finally {
lock.unlock();
}
}
/**
*
* @param jarName a user deployed jar name (abc.jar or abc-1.0.jar)
*/
public void deleteAllVersionsOfJar(String jarName) {
lock.lock();
String artifactId = getArtifactId(jarName);
try {
for (File file : this.deployDirectory.listFiles()) {
if (artifactId.equals(toArtifactId(file.getName()))) {
logger.info("Deleting: {}", file.getAbsolutePath());
FileUtils.deleteQuietly(file);
}
}
} finally {
lock.unlock();
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder(getClass().getName());
sb.append('@').append(System.identityHashCode(this)).append('{');
sb.append("deployDirectory=").append(deployDirectory);
sb.append('}');
return sb.toString();
}
}