package geniusweb.profilesserver;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import geniusweb.issuevalue.Domain;
import geniusweb.profile.Profile;
import tudelft.utilities.files.FileInfo;
import tudelft.utilities.files.FileWatcher;
import tudelft.utilities.immutablelist.Tuple;
import tudelft.utilities.listener.Listenable;
import tudelft.utilities.listener.Listener;
import tudelft.utilities.logging.Reporter;
/**
* As {@link DefaultProfilesFactory}, but auto synchronizes the contents of the
* factory according to changes in the filesystem. Domain and profile contents
* should match their directoryname/filename. A directory can only contain files
* for the domain with the same name as the directoryname.
*
* This factory tries to use the value in the environment variable
* "PROFILES_ROOT" as base dir for the available profiles. If it does not exist,
* it assumes "repo" in the current working directory. It is assumed ot contain
* a directory for each domain, with the name of the directory matching the
* {@link Domain#getName()}. We recomend to set an absolute path to
* PROFILES_ROOT to avoid problems if something changes the working directory.
*
* Thread safe. It is heavily synchronized to ensure thread safety. It does not
* manipulate the filesystem to avoid collisions between trackers and updaters.
*
* Tech note: This is not a singleton because we need to override functions for
* testing purposes.
*/
public class AutoUpdatingProfilesFactory extends DefaultProfilesFactory {
private static final String PROFILES_ROOTDIR = "PROFILES_ROOTDIR";
private static final String DOMAINSREPO = "domainsrepo";
private static final String JSON = ".json";
private final Listenable> watcher;
private Path rootdir;
private final Reporter log;
public AutoUpdatingProfilesFactory(Reporter logger) {
this.log = logger;
this.rootdir = getRootDir();
watcher = getFileWatcher(rootdir.toFile());
watcher.addListener(new Listener>() {
@Override
public void notifyChange(Tuple info) {
update(info);
}
});
// manually trigger the first event.
update(new Tuple(null,
new FileInfo(rootdir.toFile(), 2)));
}
@Override
public synchronized void putProfile(Profile profile) throws IOException {
if (!available.containsKey(profile.getDomain())) {
throw new IOException("profile's domain does not exist");
}
String domainname = profile.getDomain().getName();
String profilename = profile.getName();
if (!profilename.matches("[a-zA-Z0-9-]+"))
throw new IOException("illegal profile name " + profilename
+ ", only letters, numbers and - allowed");
File file = Paths
.get(rootdir.toString(), domainname, profilename + JSON)
.toFile();
if (file.exists()) {
String msg = fileCheck(file, false, true);
if (msg != null)
throw new IOException(msg);
}
// CHECK utf-8 compatibility? Do we need that anyway?
try (Writer out = new FileWriter(file, false)) {
out.write(Jackson.instance().writeValueAsString(profile));
}
// trigger the update immediately, avoid wait for FileChecker.
super.add(profile);
}
/**
* Called when some file that may be relevant has changed. Synchronized to
* avoid weird states if filesystem changes rapidly.
*/
private synchronized void update(Tuple info) {
log.log(Level.INFO, "change: " + info);
File oldfile = info.get1() == null ? null : info.get1().getFile();
File newfile = info.get2() == null ? null : info.get2().getFile();
File file = oldfile != null ? oldfile : newfile;
Path relpath = file.toPath().relativize(rootdir);
// workaround: root gives "" with depth 1 instead of 0.
int depth = relpath.toString().isEmpty() ? 0 : relpath.getNameCount();
switch (depth) {
case 0:
// root changed! Update ALL directories we saw and see.
reset();
break;
case 1: // change of directory name?
if (newfile != null) {
if (newfile.isDirectory()) {
updateDomainDirectory(newfile);
} else {
log.log(Level.INFO, "expected directory but found file: "
+ newfile.getAbsolutePath());
}
} else {
// new file is null. Removed
remove(getDomain(oldfile.getName()));
}
break;
case 2: // A file changed, added or removed inside profile directory
if (newfile != null) {
// file was added.
if (newfile.isDirectory()) {
log.log(Level.INFO,
"expected file but found directory: " + file);
}
updateDomainDirectory(newfile.getParentFile());
} else {
// Some file was removed. Check entire directory again
updateDomainDirectory(oldfile.getParentFile());
}
break;
default:
log.log(Level.WARNING, "Ignoring unexpected event at " + info);
}
}
/**
* Remove all domains and reload them all from scratch
*/
private void reset() {
// first remove all old stuff. Copy domains as we're going to modify.
for (Domain domain : new LinkedList<>(available.keySet())) {
remove(domain);
}
if (!basicFileCheck(rootdir.toFile(), true))
return;
for (File dir : rootdir.toFile().listFiles()) {
if (!dir.isDirectory())
continue;
updateDomainDirectory(dir);
}
}
/**
* A domain directory was added or updated (not deleted). Scan the contents
* and compare/update the repo.
*
* @param dir the directory that could contain domain specific files -
* domain description and profiles.
*/
private void updateDomainDirectory(File dir) {
if (!basicFileCheck(dir, true)) {
remove(getDomain(dir.getName()));
return;
}
// if we get here, dir is indeed a directory and we can read contents.
File domainfile = domainFile(dir.getName());
Domain newdomain = deserializeDomain(domainfile);
Domain olddomain = getDomain(dir.getName());
if (olddomain != null && !olddomain.equals(newdomain)) {
remove(olddomain); // also removes old profiles.
}
if (newdomain == null) {
return;
}
add(newdomain);
updateProfiles(newdomain);
}
/**
* Update all profiles in given directory
*
* @param dir
*/
private void updateProfiles(Domain domain) {
final Path domaindir = rootdir.resolve(domain.getName());
final File domainfile = domainFile(domain.getName());
final List newprofiles = new LinkedList<>();
for (File file : domaindir.toFile().listFiles()) {
if (file.equals(domainfile))
continue;
Profile newprofile = deserializeProfile(file);
if (newprofile == null)
continue;
if (!newprofile.getDomain().equals(domain)) {
log.log(Level.WARNING,
"Profile " + file + " contains incorrect domain "
+ newprofile.getDomain());
continue;
}
if (!newprofile.getName()
.equals(withoutExtension(file.getName()))) {
log.log(Level.WARNING, "Profile " + file
+ " contains incorrect name " + newprofile.getName());
continue;
}
newprofiles.add(newprofile);
}
// remove profiles not on the filesystem anymore/have errors
for (Profile profile : getProfiles(domain.getName())) {
if (!newprofiles.contains(profile)) {
remove(profile);
}
}
// (re)add currently existing profiles.
for (Profile profile : newprofiles) {
add(profile);
}
}
/**
* Reads profile and also checks that this profile is in the correct
* directory (the domain should match the registered domain for this
* directory). The domain for this directory MUST have been registered.
*
* @param profilefiile the profile file to read. Should contain a
* {@link Profile}.
* @return profile, or null if failed to read. If failed, we log the
* details.
*/
private Profile deserializeProfile(File profilefile) {
if (!basicFileCheck(profilefile, false))
return null;
Profile profile = null;
try {
profile = Jackson.instance().readValue(profilefile, Profile.class);
} catch (IOException e) {
log.log(Level.WARNING, "File " + profilefile
+ " does not appear to contain a profile", e);
return null;
}
Domain expectedDomain = getDomain(
profilefile.getParentFile().getName());
if (!profile.getDomain().equals(expectedDomain)) {
log.log(Level.WARNING, "Profile has incorrect domain: expected "
+ expectedDomain + " but found " + profile.getDomain());
return null;
}
return profile;
}
/**
* Try to deserialize a domain file in given directory.
*
* @param dir the directory where we expect a domain file
* @return deserialized domain file, or null if deserialization fails (eg no
* such file, or wrong contents, domain has name not matching the
* actual file). If fails, a warning is logged with details.
*
*
*/
private Domain deserializeDomain(File domainfile) {
if (!basicFileCheck(domainfile, false))
return null;
Domain domain = null;
String expectedName = withoutExtension(domainfile.getName());
try {
domain = Jackson.instance().readValue(domainfile, Domain.class);
} catch (IOException e) {
log.log(Level.WARNING, "File " + domainfile
+ " does not appear to contain a Domain", e);
return null;
}
if (!expectedName.equals(domain.getName())) {
log.log(Level.WARNING,
"domain file " + domainfile + " contains name:'"
+ domain.getName()
+ "' which does not match the required name "
+ expectedName);
return null;
}
return domain;
}
/**
* Silent check and report basic problems with a file. Silent in the sense
* that errors are logged but cause no exception.
*
* @param file the file to check
* @param shouldbeDirectory true if the file should be a directory
* @return false iff basic checks on the file fails.
*
*/
private boolean basicFileCheck(File file, boolean shouldbeDirectory) {
String msg = fileCheck(file, shouldbeDirectory, false);
if (msg != null) {
log.log(Level.WARNING, msg);
return false;
}
return true;
}
/**
*
* @param file the file to check
* @param shouldbeDirectory true if expected a directory
* @param shouldbeWritable true if expected to be writable
* @return null if file is there, is file/directory, allows reading and
* allows writing (if specified).
*/
private String fileCheck(File file, boolean shouldbeDirectory,
boolean shouldbeWritable) {
if (!file.exists()) {
return file + " is missing";
}
if (!file.canRead()) {
return "directory " + file + "does not allow reading ";
}
if (shouldbeDirectory) {
if (!file.isDirectory()) {
return file + " is not a directory";
}
} else {
if (!file.isFile()) {
return file + " is not a file";
}
}
if (shouldbeWritable && !file.canWrite()) {
return file + "is read-only";
}
return null;
}
private String withoutExtension(String name) {
if (!name.contains("."))
return name;
return name.substring(0, name.indexOf("."));
}
/**
* @param directory a directory that might contain a domain file.
* @return the file that contains the domain description in the given
* directory.
*
*/
private File domainFile(String domainname) {
return Paths.get(rootdir.toString(), domainname, domainname + JSON)
.toFile();
}
/**
* Factory method that gives a watcher for file changes. See
* {@link FileWatcher}.
*
* @param file
* @return a class that watches for file changes below the given file.
*/
protected Listenable> getFileWatcher(File file) {
return new FileWatcher(file, 3000, 2);
}
/**
* Search from location of this class upwards till we find a directory
* called "domainsrepo".
*
* @return the root dir of the repo. This is the 'database' where all known
* profiles are stored.
* @throws URISyntaxException
*/
protected Path getRootDir() {
// If you run this normal from tomcat we are somewhere inside
// projectroot/WEB_INF/classes/....
// Search back upwards to projectroot.
Path dir;
String path = System.getenv(PROFILES_ROOTDIR);
if (path != null) {
dir = Paths.get(path);
if (!dir.toFile().exists())
throw new RuntimeException("Hard set path PROFILES_ROOTDIR='"
+ PROFILES_ROOTDIR + "' does not exist ");
return dir;
}
try {
dir = Paths.get(getClass().getProtectionDomain().getCodeSource()
.getLocation().toURI()).getParent();
} catch (URISyntaxException e) {
throw new RuntimeException("Problem with path URI", e);
}
System.out.println("searching from " + dir);
while (dir.getNameCount() > 1) {
Path repo = dir.resolve(DOMAINSREPO);
if (repo.toFile().exists())
return repo;
System.out.println("Directory did not contain repo:" + dir);
dir = dir.getParent();
}
throw new RuntimeException("Path to " + DOMAINSREPO + " was not found");
}
}