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 DefaultProfilesRepository}, but auto synchronizes the contents of * the repository 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 repository 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 AutoUpdatingProfilesRepository extends DefaultProfilesRepository { 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 AutoUpdatingProfilesRepository(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"); } }