001package com.pingidentity.util;
002
003import com.unboundid.directory.sdk.common.api.ManageExtensionPlugin;
004import com.unboundid.directory.sdk.common.types.ExtensionBundle;
005import com.unboundid.directory.sdk.common.types.InstallExtensionDetails;
006import com.unboundid.directory.sdk.common.types.PostManageExtensionPluginResult;
007import com.unboundid.directory.sdk.common.types.UpdateExtensionDetails;
008
009import java.io.File;
010import java.io.IOException;
011import java.nio.charset.Charset;
012import java.nio.charset.StandardCharsets;
013import java.nio.file.CopyOption;
014import java.nio.file.DirectoryStream;
015import java.nio.file.FileAlreadyExistsException;
016import java.nio.file.FileSystemLoopException;
017import java.nio.file.FileVisitResult;
018import java.nio.file.FileVisitor;
019import java.nio.file.Files;
020import java.nio.file.Path;
021import java.nio.file.Paths;
022import java.nio.file.attribute.BasicFileAttributes;
023import java.nio.file.attribute.FileTime;
024
025import static java.nio.file.FileVisitResult.CONTINUE;
026import static java.nio.file.FileVisitResult.SKIP_SUBTREE;
027import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
028
029/**
030 * Created by arnaudlacour on 5/17/17.
031 */
032@SuppressWarnings("unused")
033public class Installer extends ManageExtensionPlugin {
034    /**
035     * Returns the extension name.
036     *
037     * @return the extension name
038     */
039    @Override
040    public String getExtensionName() {
041        return "Generic Extension Installer";
042    }
043
044    /**
045     * Provides descriptions for the extension.
046     *
047     * @return an array of descriptive paragraphs
048     */
049    @Override
050    public String[] getExtensionDescription() {
051        return new String[]{"Installs the extension this installer is bundled with"};
052    }
053
054    /**
055     * Performs the necessary processing after the extension has been updated.
056     *
057     * @param details the extension bundle details
058     * @return SUCCESS if the update was successful
059     */
060    @Override
061    public PostManageExtensionPluginResult postUpdate(UpdateExtensionDetails details) {
062        File instanceRoot = details.getExtensionBundle().getExtensionBundleDir()
063                .getParentFile().getParentFile();
064        String confBatch = details.getExtensionBundle().getConfigDir() + "/update.dsconfig";
065        File configurationBatchFile = new File(confBatch);
066        if (configurationBatchFile.exists() && configurationBatchFile.isFile()) {
067            out("");
068            out("You can get started more quickly by running this command: ");
069            out(instanceRoot.toString() + "/bin/dsconfig --no-prompt --batch-file " + confBatch);
070        }
071        return PostManageExtensionPluginResult.SUCCESS;
072    }
073
074    /**
075     * Performs the necessary processing after the extension has been installed.
076     * This currently includes things like laying down schema files, updating documentation,
077     * attempting to open a browser to the extension documentation page and displaying the
078     * appropriate commands to run.
079     *
080     * @param details the extension bundle details
081     * @return SUCCESS if the install was successful
082     */
083    @Override
084    public PostManageExtensionPluginResult postInstall(
085            InstallExtensionDetails details) {
086        File instanceRoot = details.getExtensionBundle().getExtensionBundleDir()
087                .getParentFile().getParentFile();
088
089        /* copy schema files included in the extension if any */
090        copySchema(details, instanceRoot);
091
092        /* copy the documentation so it can be readily available for users */
093        copyDocumentation(instanceRoot, details);
094
095        /* Update the docs index to add a link for convenience */
096        updateIndex(instanceRoot, details);
097
098        /* if there is a browser to be opened, attempt to open the documentation directly */
099        openBrowser(details.getExtensionBundle());
100
101
102        String confBatch = details.getExtensionBundle().getConfigDir() + "/install.dsconfig";
103        File configurationBatchFile = new File(confBatch);
104        if (configurationBatchFile.exists() && configurationBatchFile.isFile()) {
105            out("");
106            out("You can get started more quickly by running this command: ");
107            out(instanceRoot.toString() + "/bin/dsconfig --no-prompt --batch-file " + confBatch);
108            out("Executing this command will create basic configuration objects.");
109        }
110        return PostManageExtensionPluginResult.SUCCESS;
111    }
112
113    /**
114     * This method performs the necessary processing to copy schema to the instance.
115     *
116     * @param details      the extension details
117     * @param instanceRoot the instance root
118     */
119    private void copySchema(final InstallExtensionDetails details, File instanceRoot) {
120        out("");
121        out("Schema preparation");
122        String schemaAddress = details.getExtensionBundle().getConfigDir() + "/schema";
123        File schemaDirectory = new File(schemaAddress);
124        if (schemaDirectory.exists() && schemaDirectory.isDirectory()) {
125            Path schemaSourcePath = Paths.get(schemaAddress);
126            try {
127                DirectoryStream<Path>
128                        schemaDirectoryStream = Files.newDirectoryStream(
129                        schemaSourcePath, "??-*.ldif");
130                for (Path schemaFile : schemaDirectoryStream) {
131                    try {
132                        out("Copying schema file " + schemaFile.toString());
133                        Files.copy(schemaFile, Paths.get(instanceRoot
134                                        + "/config/schema/" + schemaFile.getFileName()),
135                                REPLACE_EXISTING);
136                    } catch (IOException e) {
137                        out(e.getMessage());
138                    }
139                }
140            } catch (IOException e) {
141                out(e.getMessage());
142            }
143        }
144        out("Schema preparation: done.");
145    }
146
147    /**
148     * This method performs the necessary processing to attempt opening a browser window for the
149     * user at installation time and pop the documentation up.
150     *
151     * @param bundle the extension bundle to get the information from
152     */
153    private void openBrowser(final ExtensionBundle bundle) {
154        out("");
155        out("Documentation convenience");
156        String docUrl = "file://" + bundle.getDocumentationDir() + "/index.html";
157        out("You may find the documentation for this extension here:");
158        out(docUrl);
159        String os = System.getProperty("os.name").toLowerCase();
160        try {
161            if (os.contains("mac")) {
162                Runtime.getRuntime().exec("open " + docUrl);
163            } else if (os.contains("win")) {
164                Runtime.getRuntime().exec("cmd /c start " + docUrl);
165            } else if (os.contains("nux")) {
166                Runtime.getRuntime().exec("xdg-open " + docUrl);
167            }
168        } catch (IOException e) {
169            err(e.getMessage());
170        }
171        out("Documentation convenience: done.");
172    }
173
174
175    /**
176     * This method performs the necessary processing to attempt copying the documentation
177     * bundled with the extension into a folder that can be reached by the docs servlet.
178     *
179     * @param instanceRoot     the instance installation root file
180     * @param extensionDetails the extension detail information
181     */
182    private void copyDocumentation(final File instanceRoot,
183                                   InstallExtensionDetails extensionDetails) {
184        out("");
185        out("Documentation preparation");
186
187        String extensionNewDocRoot = instanceRoot + "/docs/extensions/"
188                + extensionDetails.getExtensionBundle().getBundleId();
189
190        File newDocDir = new File(extensionNewDocRoot);
191        boolean mkdirSucceeded = newDocDir.mkdirs();
192        if (mkdirSucceeded && newDocDir.exists()) {
193            Path srcDocPath = Paths.get(extensionDetails.getExtensionBundle()
194                    .getDocumentationDir().getPath());
195            Path dstDocPath = Paths.get(extensionNewDocRoot);
196
197            TreeCopier tc = new TreeCopier(srcDocPath, dstDocPath);
198            try {
199                Files.walkFileTree(srcDocPath, tc);
200            } catch (IOException e) {
201                out(e.getMessage());
202            }
203            out("Documentation preparation: done.");
204        } else {
205            out("Documentation preparation: skipped.");
206        }
207    }
208
209    /**
210     * This method performs the necessary processing to attempt updating the default documentation
211     * html page to add a direct link to the freshly copied extension documentation.
212     * Avoids duplicating links when updating the same extension version, for example during
213     * development.
214     *
215     * @param extensionDetails the bundle details
216     */
217    private void updateIndex(final File syncRoot, InstallExtensionDetails extensionDetails) {
218        out("");
219        out("Documentation index update");
220
221        Path path = Paths.get(syncRoot.getPath() + "/docs/index.html");
222        Charset charset = StandardCharsets.UTF_8;
223        String content;
224        try {
225            content = new String(Files.readAllBytes(path), charset);
226            String bundleId = extensionDetails.getExtensionBundle().getBundleId();
227            if (!content.contains("extensions/" + bundleId)) {
228                content = content.replaceAll("product.",
229                        "product.\n<BR><BR><BR><A HREF=\"extensions/" + bundleId
230                                + "/\">" + extensionDetails.getExtensionBundle().getTitle()
231                                + " " + extensionDetails.getExtensionBundle().getVersion() + "</A>");
232                Files.write(path, content.getBytes(charset));
233            }
234        } catch (IOException e) {
235            out(e.getMessage());
236        }
237        out("Documentation index update: done.");
238    }
239
240    /**
241     * Copy source file to target location. If {@code prompt} is true then
242     * prompt user to overwrite target if it exists. The {@code preserve}
243     * parameter determines if file attributes should be copied/preserved.
244     */
245    static void copyFile(Path source, Path target) {
246        CopyOption[] options = new CopyOption[]{REPLACE_EXISTING};
247        if (Files.notExists(target)) {
248            try {
249                Files.copy(source, target, options);
250            } catch (IOException x) {
251                err("Unable to copy: " + source + " => " + x.getMessage());
252            }
253        }
254    }
255
256    static void out(final String s) {
257        System.out.println(s);
258    }
259
260    static void err(final String s) {
261        System.err.println(s);
262    }
263
264
265    /**
266     * A {@code FileVisitor} that copies a file-tree ("cp -r").
267     */
268    @SuppressWarnings("unused")
269    static class TreeCopier implements FileVisitor<Path> {
270        private final Path source;
271        private final Path target;
272
273        TreeCopier(Path source, Path target) {
274            this.source = source;
275            this.target = target;
276        }
277
278        @Override
279        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
280            // before visiting entries in a directory we copy the directory
281            // (okay if directory already exists).
282            CopyOption[] options = new CopyOption[0];
283
284            Path newDir = target.resolve(source.relativize(dir));
285            try {
286                Files.copy(dir, newDir, options);
287            } catch (FileAlreadyExistsException x) {
288                // ignore
289            } catch (IOException x) {
290                System.err.format("Unable to create: %s: %s%n", newDir, x);
291                return SKIP_SUBTREE;
292            }
293            return CONTINUE;
294        }
295
296        @Override
297        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
298            copyFile(file, target.resolve(source.relativize(file)));
299            return CONTINUE;
300        }
301
302        @Override
303        public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
304            // fix up modification time of directory when done
305            if (exc == null) {
306                Path newDir = target.resolve(source.relativize(dir));
307                try {
308                    FileTime time = Files.getLastModifiedTime(dir);
309                    Files.setLastModifiedTime(newDir, time);
310                } catch (IOException x) {
311                    System.err.format("Unable to copy all attributes to: %s: %s%n", newDir, x);
312                }
313            }
314            return CONTINUE;
315        }
316
317        @Override
318        public FileVisitResult visitFileFailed(Path file, IOException exc) {
319            if (exc instanceof FileSystemLoopException) {
320                Installer.err("cycle detected: " + file);
321            } else {
322                Installer.err("Unable to copy: " + file + " => " + exc.getMessage());
323            }
324            return CONTINUE;
325        }
326    }
327}