mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
21 hours ago 44b37afa94f2aa6dc575835ac508ed214f6b983a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2024-2026 3A Systems LLC.
 */
 
package org.openidentityplatform.opendj.embedded;
 
import org.apache.commons.io.FileUtils;
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.opendj.config.client.ManagementContext;
import org.forgerock.opendj.ldap.Connection;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.LdapException;
import org.forgerock.opendj.ldap.SearchScope;
import org.forgerock.opendj.ldap.responses.SearchResultEntry;
import org.forgerock.opendj.ldif.ConnectionEntryReader;
import org.forgerock.opendj.ldif.EntryReader;
import org.forgerock.opendj.ldif.LDIFEntryReader;
import org.forgerock.opendj.ldif.LDIFEntryWriter;
import org.forgerock.opendj.server.config.client.BackendCfgClient;
import org.forgerock.opendj.server.embedded.ConfigParameters;
import org.forgerock.opendj.server.embedded.ConnectionParameters;
import org.forgerock.opendj.server.embedded.EmbeddedDirectoryServer;
import org.forgerock.opendj.server.embedded.EmbeddedDirectoryServerException;
import org.forgerock.opendj.server.embedded.SetupParameters;
import org.forgerock.opendj.server.embedded.UpgradeParameters;
import org.opends.server.backends.MemoryBackend;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
 
import java.io.BufferedReader;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
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;
 
    final Config config;
 
    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());
    }
 
    public EmbeddedOpenDJ(Config config) {
 
        logger.info("Create embedded OpenDJ instance: {}", config);
 
        this.config = config;
        File instanceDirectory = null;
        try {
            // Create a fresh per-instance parent directory, only accessible by the
            // current user, instead of the fixed shared {java.io.tmpdir}/opendj
            // directory. The server root inside it must be named "opendj" because
            // setup from an archive requires the server root directory to match the
            // root directory contained in the archive. The whole directory is
            // deleted on close().
            instanceDirectory = Files.createTempDirectory("opendj").toFile();
            File rootDirectory = new File(instanceDirectory, "opendj");
            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");
            File schemaDirectory = new File(configDirectory, "schema");
            server = EmbeddedDirectoryServer.manageEmbeddedDirectoryServer(
                    ConfigParameters.configParams()
                            .serverRootDirectory(rootDirectory.getPath())
                            .configurationFile(Paths.get(rootDirectory.getPath(), "config", "config.ldif").toString()),
                    ConnectionParameters.connectionParams()
                            .hostName("localhost")
                            .ldapPort(config.getPort())
                            .adminPort(config.getAdminPort())
                            .bindDn("cn=Directory Manager")
                            .bindPassword(config.getAdminPassword()),
                    System.out,
                    System.err);
 
            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()
                            .baseDn(config.getBaseDN())
                            .backendType(config.getBackendType())
                            .jmxPort(config.getJmxPort())
            );
 
            List<String> schemaFiles = new ArrayList<>();
            if (config.getLdifSchema() != null) {
                schemaFiles.add(config.getLdifSchema());
            }
 
            copyFilesFromJar(schemaFiles, JAR_SCHEMA_DIRECTORY, schemaDirectory);
 
            this.instanceDirectory = instanceDirectory;
            this.rootDirectory = rootDirectory;
        }catch (Exception e) {
            logger.error("Error initializing OpenDJ");
            deleteInstanceDirectory(instanceDirectory, shortDeleteTimeout(config));
            throw new RuntimeException(e);
        }
        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 managementContext = server.getConfiguration()) {
                BackendCfgClient userRoot = managementContext.getRootConfiguration().getBackend("userRoot");
                userRoot.setBaseDN((Collections.singletonList(baseDN)));
                userRoot.setEnabled(true);
                userRoot.commit();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
 
            logger.info("Check upgrade OpenDJ ...");
            server.upgrade(UpgradeParameters.upgradeParams().isIgnoreErrors(false));
 
            logger.info("Start OpenDJ ...");
            server.start();
 
        } catch (Exception e) {
            logger.error("Error starting OpenDJ", 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 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.
            }
        }
        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{
        for(String jarFile : jarFiles) {
            File outputFile = new File(outputDirectory, new File(jarFile).getName());
            final String resourcePath = !jarFile.contains("/")
                    ? "/"+jarDirectory + jarFile
                    : jarFile;
            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);
                }
                FileUtils.copyInputStreamToFile(in, outputFile);
            }
        }
    }
 
    /**
     * 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");
 
        org.forgerock.opendj.ldap.Entry  entryBefore;
        long recordCount = 0;
        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");
        }
    }
 
    /**
     * 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 {
        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());
                    }
                }
            }
        }
    }
 
    private boolean skipEntry(SearchResultEntry se) {
        for (String skip : config.getSkipSet()) {
            if (se.getName().toString().toLowerCase().contains(skip)){
                logger.trace("ignore export {}", se);
                return true;
            }
        }
        return false;
    }
 
    public boolean isRunning() {
        return server != null && server.isRunning();
    }
 
}