View Javadoc
1   /*
2    * Copyright (c) 2012-2023, jcabi.com
3    * All rights reserved.
4    *
5    * Redistribution and use in source and binary forms, with or without
6    * modification, are permitted provided that the following conditions
7    * are met: 1) Redistributions of source code must retain the above
8    * copyright notice, this list of conditions and the following
9    * disclaimer. 2) Redistributions in binary form must reproduce the above
10   * copyright notice, this list of conditions and the following
11   * disclaimer in the documentation and/or other materials provided
12   * with the distribution. 3) Neither the name of the jcabi.com nor
13   * the names of its contributors may be used to endorse or promote
14   * products derived from this software without specific prior written
15   * permission.
16   *
17   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
19   * NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
20   * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
21   * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
22   * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23   * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24   * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
25   * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
26   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
28   * OF THE POSSIBILITY OF SUCH DAMAGE.
29   */
30  package com.jcabi.mysql.maven.plugin;
31  
32  import com.jcabi.aspects.Loggable;
33  import com.jcabi.log.Logger;
34  import com.jcabi.log.VerboseProcess;
35  import com.jcabi.log.VerboseRunnable;
36  import java.io.File;
37  import java.io.IOException;
38  import java.io.OutputStreamWriter;
39  import java.io.PrintWriter;
40  import java.nio.charset.StandardCharsets;
41  import java.nio.file.Files;
42  import java.nio.file.Path;
43  import java.nio.file.Paths;
44  import java.util.Arrays;
45  import java.util.Collection;
46  import java.util.LinkedList;
47  import java.util.concurrent.Callable;
48  import java.util.concurrent.ConcurrentHashMap;
49  import java.util.concurrent.ConcurrentMap;
50  import java.util.concurrent.TimeUnit;
51  import javax.validation.constraints.NotNull;
52  import lombok.EqualsAndHashCode;
53  import lombok.ToString;
54  import org.apache.commons.io.FileUtils;
55  import org.apache.commons.lang3.StringUtils;
56  
57  /**
58   * Running instances of MySQL.
59   *
60   * <p>The class is thread-safe.
61   * @checkstyle ClassDataAbstractionCoupling (500 lines)
62   * @checkstyle MultipleStringLiterals (500 lines)
63   * @since 0.1
64   */
65  @ToString
66  @EqualsAndHashCode(of = "processes")
67  @Loggable(Loggable.INFO)
68  @SuppressWarnings({ "PMD.DoNotUseThreads", "PMD.TooManyMethods" })
69  public final class Instances {
70  
71      /**
72       * Directory of the actual database relative to the target.
73       */
74      private static final String DATA_SUB_DIR = "data";
75  
76      /**
77       * No defaults.
78       */
79      private static final String NO_DEFAULTS = "--no-defaults";
80  
81      /**
82       * Default retry count.
83       */
84      private static final int RETRY_COUNT = 5;
85  
86      /**
87       * Default user.
88       */
89      private static final String DEFAULT_USER = "root";
90  
91      /**
92       * Default password.
93       */
94      private static final String DEFAULT_PASSWORD = "root";
95  
96      /**
97       * Default host.
98       */
99      @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
100     private static final String DEFAULT_HOST = "127.0.0.1";
101 
102     /**
103      * Running processes.
104      */
105     private final transient ConcurrentMap<Integer, Process> processes =
106         new ConcurrentHashMap<>(0);
107 
108     /**
109      * If true, always create a new database. If false, check if there is an
110      * existing database at the target location and try to use that if
111      * possible, otherwise create a new one anyway.
112      */
113     private transient boolean clean = true;
114 
115     /**
116      * Start a new one at this port.
117      * @param config Instance configuration
118      * @param dist Path to MySQL distribution
119      * @param target Where to keep temp data
120      * @param deldir If existing DB should be deleted
121      * @param socket Alternative socket location for mysql (may be null)
122      * @throws IOException If fails to start
123      * @checkstyle ParameterNumberCheck (10 lines)
124      */
125     public void start(@NotNull final Config config, @NotNull final File dist,
126         @NotNull final File target, final boolean deldir, final File socket)
127         throws IOException {
128         this.setClean(target, deldir);
129         synchronized (this.processes) {
130             if (this.processes.containsKey(config.port())) {
131                 throw new IllegalArgumentException(
132                     String.format("Port %d is already busy", config.port())
133                 );
134             }
135             final Process proc = this.process(config, dist, target, socket);
136             this.processes.put(config.port(), proc);
137             Runtime.getRuntime().addShutdownHook(
138                 new Thread(() -> this.stop(config.port()))
139             );
140         }
141         Logger.info(
142             this,
143             "MySQL database is up and running at the %d port",
144             config.port()
145         );
146     }
147 
148     /**
149      * Stop a running one at this port.
150      * @param port The port to stop at
151      */
152     public void stop(final int port) {
153         synchronized (this.processes) {
154             final Process proc = this.processes.remove(port);
155             if (proc != null) {
156                 proc.destroy();
157             }
158         }
159     }
160 
161     /**
162      * Returns if a clean database had to be created. Note that this must be
163      * called after {@link Instances#start(Config, File, File, boolean)}.
164      * @return If this is a clean database or could have been reused
165      */
166     public boolean reusedExistingDatabase() {
167         return !this.clean;
168     }
169 
170     /**
171      * Start a new process.
172      * @param config Instance configuration
173      * @param dist Path to MySQL distribution
174      * @param target Where to keep temp data
175      * @param socketfile Alternative socket location for mysql (may be null)
176      * @return Process started
177      * @throws IOException If fails to start
178      * @checkstyle ParameterNumberCheck (10 lines)
179      */
180     private Process process(@NotNull final Config config,
181         final File dist, final File target, final File socketfile)
182         throws IOException {
183         final File temp = this.prepareFolders(target);
184         final File socket;
185         if (socketfile == null) {
186             socket = new File(target, "mysql.sock");
187         } else {
188             socket = socketfile;
189         }
190         final ProcessBuilder builder = this.builder(
191             dist,
192             "bin/mysqld",
193             Instances.NO_DEFAULTS,
194             String.format("--user=%s", System.getProperty("user.name")),
195             "--general_log",
196             "--console",
197             "--innodb_buffer_pool_size=64M",
198             "--innodb_log_file_size=64M",
199             "--innodb_use_native_aio=0",
200             String.format("--binlog-ignore-db=%s", config.dbname()),
201             String.format("--basedir=%s", dist),
202             String.format("--lc-messages-dir=%s", new File(dist, "share")),
203             String.format("--datadir=%s", this.data(dist, target)),
204             String.format("--tmpdir=%s", temp),
205             String.format("--socket=%s", socket),
206             String.format("--log-error=%s", new File(target, "errors.log")),
207             String.format("--general-log-file=%s", new File(target, "mysql.log")),
208             String.format("--pid-file=%s", new File(target, "mysql.pid")),
209             String.format("--port=%d", config.port())
210         ).redirectErrorStream(true);
211         builder.environment().put("MYSQL_HOME", dist.getAbsolutePath());
212         for (final String option : config.options()) {
213             if (!StringUtils.isBlank(option)) {
214                 builder.command().add(String.format("--%s", option));
215             }
216         }
217         final Process proc = builder.start();
218         final Thread thread = new Thread(
219             new VerboseRunnable(
220                 (Callable<Void>) () -> {
221                     new VerboseProcess(proc).stdoutQuietly();
222                     return null;
223                 }
224             )
225         );
226         thread.setDaemon(true);
227         thread.start();
228         this.waitFor(socket, config.port());
229         if (this.clean) {
230             this.configure(config, dist, socket);
231         }
232         return proc;
233     }
234 
235     /**
236      * Prepare the folder structure for the database if necessary.
237      * @param target Location of the database
238      * @return The location of the temp directory
239      * @throws IOException If fails to create temp directory
240      */
241     private File prepareFolders(final File target) throws IOException {
242         if (this.clean && target.exists()) {
243             FileUtils.deleteDirectory(target);
244             Logger.info(this, "deleted %s directory", target);
245         }
246         if (!target.exists() && target.mkdirs()) {
247             Logger.info(this, "created %s directory", target);
248         }
249         final File temp = new File(target, "temp");
250         if (!temp.exists() && !temp.mkdirs()) {
251             throw new IllegalStateException(
252                 "Error during temporary folder creation"
253             );
254         }
255         return temp;
256     }
257 
258     /**
259      * Prepare and return data directory.
260      * @param dist Path to MySQL distribution
261      * @param target Where to create it
262      * @return Directory created
263      * @throws IOException If fails
264      */
265     private File data(final File dist, final File target) throws IOException {
266         final File dir = new File(target, Instances.DATA_SUB_DIR);
267         if (!dir.exists()) {
268             final File cnf = new File(
269                 new File(dist, "share"),
270                 "my-default.cnf"
271             );
272             FileUtils.writeStringToFile(
273                 cnf,
274                 "[mysql]\n# no defaults...",
275                 StandardCharsets.UTF_8
276             );
277             final Path installer = Paths.get(dist.getAbsolutePath())
278                 .resolve("scripts/mysql_install_db");
279             if (Files.exists(installer)) {
280                 new VerboseProcess(
281                     this.builder(
282                         dist,
283                         "scripts/mysql_install_db",
284                         String.format("--defaults-file=%s", cnf),
285                         "--force",
286                         "--innodb_use_native_aio=0",
287                         String.format("--datadir=%s", dir),
288                         String.format("--basedir=%s", dist)
289                     )
290                 ).stdout();
291             } else {
292                 new VerboseProcess(
293                     this.builder(
294                         dist,
295                         "bin/mysqld",
296                         "--initialize-insecure",
297                         String.format("--user=%s", Instances.DEFAULT_USER),
298                         String.format("--datadir=%s", dir),
299                         String.format("--basedir=%s", dist),
300                         String.format("--log-error=%s", new File(target, "errors.log")),
301                         String.format("--general-log-file=%s", new File(target, "mysql.log"))
302                     )
303                 ).stdout();
304             }
305         }
306         return dir;
307     }
308 
309     /**
310      * Wait for this file to become available.
311      * @param socket The file to wait for
312      * @param port Port to wait for
313      * @return The same socket, but ready for usage
314      * @throws IOException If fails
315      */
316     private File waitFor(final File socket, final int port) throws IOException {
317         final long start = System.currentTimeMillis();
318         long age = 0L;
319         while (true) {
320             if (socket.exists()) {
321                 Logger.info(
322                     this,
323                     "Socket %s is available after %[ms]s of waiting",
324                     socket, age
325                 );
326                 break;
327             }
328             if (SocketHelper.isOpen(port)) {
329                 Logger.info(
330                     this,
331                     "Port %s is available after %[ms]s of waiting",
332                     port, age
333                 );
334                 break;
335             }
336             try {
337                 TimeUnit.SECONDS.sleep(1L);
338             } catch (final InterruptedException ex) {
339                 Thread.currentThread().interrupt();
340                 throw new IllegalStateException(ex);
341             }
342             age = System.currentTimeMillis() - start;
343             if (age > TimeUnit.MINUTES.toMillis((long) 5)) {
344                 throw new IOException(
345                     Logger.format(
346                         "Socket %s is not available after %[ms]s of waiting",
347                         socket, age
348                     )
349                 );
350             }
351         }
352         return socket;
353     }
354 
355     /**
356      * Configure the running MySQL server.
357      * @param config Instance configuration
358      * @param dist Directory with MySQL distribution
359      * @param socket Socket of it
360      * @throws IOException If fails
361      */
362     private void configure(@NotNull final Config config,
363         final File dist, final File socket)
364         throws IOException {
365         new VerboseProcess(
366             this.builder(
367                 dist,
368                 "bin/mysqladmin",
369                 Instances.NO_DEFAULTS,
370                 String.format("--wait=%d", Instances.RETRY_COUNT),
371                 String.format("--port=%d", config.port()),
372                 String.format("--user=%s", Instances.DEFAULT_USER),
373                 String.format("--socket=%s", socket),
374                 String.format("--host=%s", Instances.DEFAULT_HOST),
375                 "password",
376                 Instances.DEFAULT_PASSWORD
377             )
378         ).stdout();
379         Logger.info(
380             this,
381             "Root password '%s' set for the '%s' user",
382             Instances.DEFAULT_PASSWORD,
383             Instances.DEFAULT_USER
384         );
385         final Process process =
386             this.builder(
387                 dist,
388                 "bin/mysql",
389                 String.format("--port=%d", config.port()),
390                 String.format("--user=%s", Instances.DEFAULT_USER),
391                 String.format("--password=%s", Instances.DEFAULT_PASSWORD),
392                 String.format("--socket=%s", socket)
393             ).start();
394         final PrintWriter writer = new PrintWriter(
395             new OutputStreamWriter(
396                 process.getOutputStream(),
397                 StandardCharsets.UTF_8
398             )
399         );
400         writer.print("CREATE DATABASE ");
401         writer.print(config.dbname());
402         writer.println(";");
403         if (!Instances.DEFAULT_USER.equals(config.user())) {
404             writer.println(
405                 String.format(
406                     "CREATE USER '%s'@'%s' IDENTIFIED BY '%s';",
407                     config.user(),
408                     Instances.DEFAULT_HOST,
409                     config.password()
410                 )
411             );
412             writer.println(
413                 String.format(
414                     "GRANT ALL ON %s.* TO '%s'@'%s';",
415                     config.dbname(),
416                     config.user(),
417                     Instances.DEFAULT_HOST
418                 )
419             );
420             writer.println("SHOW DATABASES;");
421         }
422         writer.close();
423         new VerboseProcess(process).stdout();
424         Logger.info(
425             this,
426             "The '%s' user created in the '%s' database with the '%s' password",
427             config.user(),
428             config.dbname(),
429             config.password()
430         );
431     }
432 
433     /**
434      * Make process builder with this commands.
435      * @param dist Distribution directory
436      * @param name Name of the cmd to run
437      * @param cmds Commands
438      * @return Process builder
439      */
440     private ProcessBuilder builder(final File dist, final String name,
441         final String... cmds) {
442         String label = name;
443         final Collection<String> commands = new LinkedList<>();
444         final File exec = new File(dist, label);
445         if (exec.exists()) {
446             try {
447                 exec.setExecutable(true);
448             } catch (final SecurityException sex) {
449                 throw new IllegalStateException(sex);
450             }
451         } else {
452             label = String.format("%s.exe", name);
453             if (!new File(dist, label).exists()) {
454                 label = String.format("%s.pl", name);
455                 commands.add("perl");
456             }
457         }
458         commands.add(new File(dist, label).getAbsolutePath());
459         commands.addAll(Arrays.asList(cmds));
460         Logger.info(this, "$ %s", StringUtils.join(commands, " "));
461         return new ProcessBuilder()
462             .command(commands.toArray(new String[0]))
463             .directory(dist);
464     }
465 
466     /**
467      * Will set the {@link Instances#clean} flag, indicating if the database
468      * can be reused or if it should be deleted and recreated.
469      * @param target Location of database
470      * @param deldir Should database always be cleared
471      */
472     private void setClean(final File target, final boolean deldir) {
473         if (new File(target, Instances.DATA_SUB_DIR).exists() && !deldir) {
474             Logger.info(this, "reuse existing database %s", target);
475             this.clean = false;
476         } else {
477             this.clean = true;
478         }
479         Logger.info(this, "reuse existing database %s", !this.clean);
480     }
481 
482 }