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 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
59
60
61
62
63
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
73
74 private static final String DATA_SUB_DIR = "data";
75
76
77
78
79 private static final String NO_DEFAULTS = "--no-defaults";
80
81
82
83
84 private static final int RETRY_COUNT = 5;
85
86
87
88
89 private static final String DEFAULT_USER = "root";
90
91
92
93
94 private static final String DEFAULT_PASSWORD = "root";
95
96
97
98
99 @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
100 private static final String DEFAULT_HOST = "127.0.0.1";
101
102
103
104
105 private final transient ConcurrentMap<Integer, Process> processes =
106 new ConcurrentHashMap<>(0);
107
108
109
110
111
112
113 private transient boolean clean = true;
114
115
116
117
118
119
120
121
122
123
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
150
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
163
164
165
166 public boolean reusedExistingDatabase() {
167 return !this.clean;
168 }
169
170
171
172
173
174
175
176
177
178
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
237
238
239
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
260
261
262
263
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
311
312
313
314
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
357
358
359
360
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
435
436
437
438
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
468
469
470
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 }