| | |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | import java.util.concurrent.TimeUnit; |
| | | import java.util.function.Predicate; |
| | | |
| | | public class EmbeddedOpenDJ implements Runnable, Closeable { |
| | | private static final String JAR_SCHEMA_DIRECTORY = "opendj/config/schema/"; |
| | | |
| | | private static final String ARCHIVE_NAME = "opendj.zip"; |
| | | |
| | | /** |
| | | * How long the deletion of the instance directory is retried when there is no point in |
| | | * waiting for the full {@link Config#getDeleteTimeout() configured timeout}: from the |
| | | * shutdown hook, where a longer wait only makes Control-C look hung, and from the |
| | | * constructor, where an initialization failure has to be reported promptly. What is still |
| | | * locked when this expires is deleted on JVM exit anyway. |
| | | */ |
| | | private static final long SHORT_DELETE_TIMEOUT_MS = 1_000L; |
| | | |
| | | private static final long INITIAL_DELETE_RETRY_DELAY_MS = 50L; |
| | | |
| | | private static final long MAX_DELETE_RETRY_DELAY_MS = 500L; |
| | | |
| | | /** Upper bound on the number of leftover paths reported when the deletion fails. */ |
| | | private static final int MAX_REPORTED_REMAINING_PATHS = 20; |
| | | |
| | | final static Logger logger = LoggerFactory.getLogger(EmbeddedOpenDJ.class.getName()); |
| | | final EmbeddedDirectoryServer server; |
| | | |
| | |
| | | |
| | | private final File instanceDirectory; |
| | | private final File rootDirectory; |
| | | private final Thread shutdownHook; |
| | | |
| | | /** Written under {@code this}, read without locking so that a running {@link #close()} blocks nothing. */ |
| | | private volatile boolean closed; |
| | | |
| | | public EmbeddedOpenDJ() { |
| | | this(new Config()); |
| | |
| | | // deleted on close(). |
| | | instanceDirectory = Files.createTempDirectory("opendj").toFile(); |
| | | File rootDirectory = new File(instanceDirectory, "opendj"); |
| | | rootDirectory.mkdir(); |
| | | if (!rootDirectory.mkdir()) { |
| | | // the parent has just been created, so this only fails on a real filesystem error |
| | | throw new IOException("Cannot create the server root directory " + rootDirectory); |
| | | } |
| | | logger.info("OpenDJ server root: {}", rootDirectory); |
| | | |
| | | File configDirectory = new File(rootDirectory, "config"); |
| | |
| | | System.out, |
| | | System.err); |
| | | |
| | | copyFilesFromJar(Collections.singletonList("opendj.zip"),"embedded-opendj/",rootDirectory); |
| | | server.extractArchiveForSetup(new File(rootDirectory,"opendj.zip")); |
| | | copyFilesFromJar(Collections.singletonList(ARCHIVE_NAME),"embedded-opendj/",rootDirectory); |
| | | final File archive = new File(rootDirectory, ARCHIVE_NAME); |
| | | server.extractArchiveForSetup(archive); |
| | | // The archive is only needed for the extraction above. Keeping it would leave a |
| | | // full copy of the distribution in the temporary directory and one more file to |
| | | // delete on close(). |
| | | if (!archive.delete()) { |
| | | logger.warn("Cannot delete {} after extracting it", archive); |
| | | } |
| | | |
| | | server.setup( |
| | | SetupParameters.setupParams() |
| | |
| | | this.rootDirectory = rootDirectory; |
| | | }catch (Exception e) { |
| | | logger.error("Error initializing OpenDJ"); |
| | | FileUtils.deleteQuietly(instanceDirectory); |
| | | deleteInstanceDirectory(instanceDirectory, shortDeleteTimeout(config)); |
| | | throw new RuntimeException(e); |
| | | } |
| | | Runtime.getRuntime().addShutdownHook(new Thread(this::close)); |
| | | shutdownHook = new Thread(this::close, "EmbeddedOpenDJ shutdown hook"); |
| | | Runtime.getRuntime().addShutdownHook(shutdownHook); |
| | | } |
| | | |
| | | /** |
| | | * Returns the server root directory of this embedded instance. |
| | | * |
| | | * @return the server root directory |
| | | * @throws IllegalStateException |
| | | * If this instance has been closed, and the directory therefore deleted. |
| | | */ |
| | | public File getServerRootDirectory() { |
| | | checkNotClosed(); |
| | | return rootDirectory; |
| | | } |
| | | |
| | | @Override |
| | | public void run() { |
| | | checkNotClosed(); |
| | | try { |
| | | final DN baseDN = DN.valueOf(config.getBaseDN()); |
| | | try { |
| | | ManagementContext config = server.getConfiguration(); |
| | | BackendCfgClient userRoot = config.getRootConfiguration().getBackend("userRoot"); |
| | | try (ManagementContext managementContext = server.getConfiguration()) { |
| | | BackendCfgClient userRoot = managementContext.getRootConfiguration().getBackend("userRoot"); |
| | | userRoot.setBaseDN((Collections.singletonList(baseDN))); |
| | | userRoot.setEnabled(true); |
| | | userRoot.commit(); |
| | | config.close(); |
| | | } catch (Exception e) { |
| | | throw new RuntimeException(e); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Stops this instance, if it is still running, and deletes its temporary directory. |
| | | * <p> |
| | | * This method is idempotent: it is also registered as a JVM shutdown hook. |
| | | * <p> |
| | | * When the server cannot be stopped, this instance stays open and keeps its shutdown hook |
| | | * registered, so that a later call - or the hook at JVM exit - retries the stop. |
| | | */ |
| | | @Override |
| | | public void close() { |
| | | public synchronized void close() { |
| | | if (closed) { |
| | | return; |
| | | } |
| | | final boolean fromShutdownHook = Thread.currentThread() == shutdownHook; |
| | | if (server.isRunning()) { |
| | | try { |
| | | logger.info("Shutting down OpenDJ ..."); |
| | | server.stop(this.getClass().getName(), LocalizableMessage.raw("Stopped after receiving Control-C")); |
| | | }catch (Throwable e) { |
| | | logger.error("Error stopping OpenDJ", e); |
| | | if (!fromShutdownHook) { |
| | | // The server is still running: deleting its directory now would destroy a |
| | | // live installation. Leave this instance open, with its shutdown hook still |
| | | // registered, so that the stop can be retried. |
| | | return; |
| | | } |
| | | // The JVM is going down and there will be no later attempt, so the temporary |
| | | // directory is removed even though the server has not stopped cleanly. |
| | | } |
| | | } |
| | | // close() is also registered as a shutdown hook, so deletion must stay idempotent |
| | | FileUtils.deleteQuietly(instanceDirectory); |
| | | closed = true; |
| | | unregisterShutdownHook(); |
| | | deleteInstanceDirectory(instanceDirectory, |
| | | fromShutdownHook ? shortDeleteTimeout(config) : config.getDeleteTimeout()); |
| | | } |
| | | |
| | | private void checkNotClosed() { |
| | | if (closed) { |
| | | throw new IllegalStateException("this embedded OpenDJ instance is closed"); |
| | | } |
| | | } |
| | | |
| | | private static long shortDeleteTimeout(Config config) { |
| | | return Math.min(config.getDeleteTimeout(), SHORT_DELETE_TIMEOUT_MS); |
| | | } |
| | | |
| | | private void unregisterShutdownHook() { |
| | | try { |
| | | Runtime.getRuntime().removeShutdownHook(shutdownHook); |
| | | } catch (IllegalStateException e) { |
| | | // close() was reached from the shutdown hook itself: nothing to unregister |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Deletes the temporary directory of an instance, retrying for a bounded period and |
| | | * falling back to a deletion on JVM exit. |
| | | * <p> |
| | | * Deleting once is not enough. On Windows a file cannot be deleted while a handle to it |
| | | * is still open, and {@code server.stop()} does not wait for the server threads to |
| | | * terminate: an embedded server runs them as daemon threads, which the shutdown monitor |
| | | * of the directory server ignores. Some handles are therefore released shortly after |
| | | * {@code stop()} has returned, and a single best-effort deletion loses that race and |
| | | * silently leaks the whole directory, backend data included. Retrying is a way to wait |
| | | * for those handles to be released - a single attempt already removes everything that is |
| | | * not in use at that moment. |
| | | * |
| | | * @param directory |
| | | * the directory to delete, may be {@code null} when the instance failed to |
| | | * initialize before creating it |
| | | * @param timeoutMs |
| | | * how long the deletion is retried, in milliseconds |
| | | */ |
| | | static void deleteInstanceDirectory(File directory, long timeoutMs) { |
| | | deleteInstanceDirectory(directory, timeoutMs, FileUtils::deleteQuietly); |
| | | } |
| | | |
| | | /** |
| | | * Implements {@link #deleteInstanceDirectory(File, long)} with an injectable deletion, so |
| | | * that tests can drive the retries without depending on a genuinely undeletable file. |
| | | * |
| | | * @param deleteAttempt |
| | | * performs one deletion attempt and reports whether the directory is gone |
| | | */ |
| | | static void deleteInstanceDirectory(File directory, long timeoutMs, Predicate<File> deleteAttempt) { |
| | | if (directory == null || !directory.exists()) { |
| | | return; |
| | | } |
| | | final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); |
| | | long retryDelay = INITIAL_DELETE_RETRY_DELAY_MS; |
| | | while (true) { |
| | | // The second test covers a directory removed by someone else in the meantime: |
| | | // deleteQuietly() reports a failure for a directory that is already gone. |
| | | if (deleteAttempt.test(directory) || !directory.exists()) { |
| | | return; |
| | | } |
| | | if (System.nanoTime() >= deadline) { |
| | | logger.warn("Cannot delete {} within {} ms, some files are still in use. " |
| | | + "They are now scheduled for deletion on JVM exit:{}", |
| | | directory, timeoutMs, remainingPaths(directory)); |
| | | break; |
| | | } |
| | | try { |
| | | Thread.sleep(retryDelay); |
| | | } catch (InterruptedException e) { |
| | | Thread.currentThread().interrupt(); |
| | | logger.warn("Interrupted while deleting {}. What is left is now scheduled " |
| | | + "for deletion on JVM exit:{}", directory, remainingPaths(directory)); |
| | | break; |
| | | } |
| | | retryDelay = Math.min(retryDelay * 2, MAX_DELETE_RETRY_DELAY_MS); |
| | | } |
| | | deleteTreeOnExit(directory); |
| | | } |
| | | |
| | | /** |
| | | * Describes what is left in the given directory: one path per line, at most |
| | | * {@link #MAX_REPORTED_REMAINING_PATHS} of them, followed by the number of paths left out. |
| | | */ |
| | | static String remainingPaths(File directory) { |
| | | final List<String> reported = new ArrayList<>(); |
| | | final int total = collectRemainingPaths(directory, reported); |
| | | final StringBuilder description = new StringBuilder(); |
| | | for (String path : reported) { |
| | | description.append("\n ").append(path); |
| | | } |
| | | if (total > reported.size()) { |
| | | description.append("\n ... and ").append(total - reported.size()).append(" more"); |
| | | } |
| | | return description.toString(); |
| | | } |
| | | |
| | | /** |
| | | * Adds at most {@link #MAX_REPORTED_REMAINING_PATHS} paths of the given tree to |
| | | * {@code reported} and returns how many paths it holds in total. |
| | | */ |
| | | private static int collectRemainingPaths(File file, List<String> reported) { |
| | | final File[] children = file.listFiles(); |
| | | if (children == null || children.length == 0) { |
| | | if (reported.size() < MAX_REPORTED_REMAINING_PATHS) { |
| | | reported.add(file.getPath()); |
| | | } |
| | | return 1; |
| | | } |
| | | int total = 0; |
| | | for (File child : children) { |
| | | total += collectRemainingPaths(child, reported); |
| | | } |
| | | return total; |
| | | } |
| | | |
| | | private static void deleteTreeOnExit(File file) { |
| | | // File.deleteOnExit() deletes in reverse order of registration, so a directory has to |
| | | // be registered before its content for the content to be removed first. |
| | | file.deleteOnExit(); |
| | | final File[] children = file.listFiles(); |
| | | if (children != null) { |
| | | for (File child : children) { |
| | | deleteTreeOnExit(child); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private void copyFilesFromJar(List<String> jarFiles, String jarDirectory, File outputDirectory) throws IOException{ |
| | |
| | | final String resourcePath = !jarFile.contains("/") |
| | | ? "/"+jarDirectory + jarFile |
| | | : jarFile; |
| | | InputStream in = new File(jarFile).exists() |
| | | try (InputStream in = new File(jarFile).exists() |
| | | ? Files.newInputStream(new File(jarFile).toPath()) |
| | | : MemoryBackend.class.getResourceAsStream(resourcePath); |
| | | if (in == null) { |
| | | throw new IOException("cannot find " + resourcePath); |
| | | : MemoryBackend.class.getResourceAsStream(resourcePath)) { |
| | | if (in == null) { |
| | | throw new IOException("cannot find " + resourcePath); |
| | | } |
| | | FileUtils.copyInputStreamToFile(in, outputFile); |
| | | } |
| | | FileUtils.copyInputStreamToFile(in, outputFile); |
| | | in.close(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Imports the LDIF read from the given stream. |
| | | * <p> |
| | | * The stream is closed by this method, whether the import succeeds or not. |
| | | * |
| | | * @param inputStream |
| | | * the LDIF to import |
| | | * @throws IllegalStateException |
| | | * If this instance has been closed. |
| | | */ |
| | | public void importData(InputStream inputStream) throws EmbeddedDirectoryServerException, IOException { |
| | | checkNotClosed(); |
| | | logger.info("start import ldif from stream"); |
| | | |
| | | EntryReader reader; |
| | | try { |
| | | BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); |
| | | reader = new LDIFEntryReader(bufferedReader); |
| | | } catch (Exception e) { |
| | | logger.error("import ldif : {}", e, e); |
| | | throw e; |
| | | } |
| | | org.forgerock.opendj.ldap.Entry entryBefore; |
| | | final Connection connection = server.getInternalConnection(); |
| | | long recordCount = 0; |
| | | while (reader.hasNext() && (entryBefore = reader.readEntry()) != null) { |
| | | recordCount++; |
| | | try { |
| | | connection.add(entryBefore); |
| | | logger.info("import ldif : {}",entryBefore.getName()); |
| | | }catch (LdapException e) { |
| | | logger.error("import ldif : {} {}",entryBefore.getName(),e.toString()); |
| | | try (EntryReader reader = new LDIFEntryReader(new BufferedReader(new InputStreamReader(inputStream))); |
| | | Connection connection = server.getInternalConnection()) { |
| | | while (reader.hasNext() && (entryBefore = reader.readEntry()) != null) { |
| | | recordCount++; |
| | | try { |
| | | connection.add(entryBefore); |
| | | logger.info("import ldif : {}",entryBefore.getName()); |
| | | }catch (LdapException e) { |
| | | logger.error("import ldif : {} {}",entryBefore.getName(),e.toString()); |
| | | } |
| | | } |
| | | } |
| | | if(recordCount == 0) { |
| | | logger.error("no records were imported, check file contents and permissions"); |
| | | throw new RuntimeException("no records were imported"); |
| | | } |
| | | reader.close(); |
| | | connection.close(); |
| | | } |
| | | |
| | | /** |
| | | * Writes the entries below the given base DN to the given stream, as LDIF. |
| | | * <p> |
| | | * The stream is flushed and closed by this method, whether the export succeeds or not. |
| | | * |
| | | * @param baseDN |
| | | * the base DN of the subtree to export |
| | | * @param out |
| | | * where the LDIF is written |
| | | * @throws IllegalStateException |
| | | * If this instance has been closed. |
| | | */ |
| | | public void getData(String baseDN, OutputStream out) throws IOException, EmbeddedDirectoryServerException { |
| | | LDIFEntryWriter ldifWriter = new LDIFEntryWriter(out); |
| | | final Connection connection = server.getInternalConnection(); |
| | | |
| | | ConnectionEntryReader reader = connection.search(baseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=*)"); |
| | | while(reader.hasNext()) { |
| | | if (!reader.isReference()) { |
| | | SearchResultEntry se = reader.readEntry(); |
| | | if (!skipEntry(se)) { |
| | | ldifWriter.writeEntry(se); |
| | | logger.info("export {}", se.toString()); |
| | | checkNotClosed(); |
| | | // resources are closed in reverse order, so the writer is flushed and closed last |
| | | try (LDIFEntryWriter ldifWriter = new LDIFEntryWriter(out); |
| | | Connection connection = server.getInternalConnection(); |
| | | ConnectionEntryReader reader = |
| | | connection.search(baseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=*)")) { |
| | | while (reader.hasNext()) { |
| | | if (!reader.isReference()) { |
| | | SearchResultEntry se = reader.readEntry(); |
| | | if (!skipEntry(se)) { |
| | | ldifWriter.writeEntry(se); |
| | | logger.info("export {}", se.toString()); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | reader.close(); |
| | | ldifWriter.close(); |
| | | connection.close(); |
| | | } |
| | | |
| | | private boolean skipEntry(SearchResultEntry se) { |