Project

General

Profile

Download (15.8 KB) Statistics
| Branch: | Tag: | Revision:
1
/**
2
* Copyright (C) 2020 EDIT
3
* European Distributed Institute of Taxonomy
4
* http://www.e-taxonomy.eu
5
*
6
* The contents of this file are subject to the Mozilla Public License Version 1.1
7
* See LICENSE.TXT at the top of this package for the full license terms.
8
*/
9
package eu.etaxonomy.drush;
10

    
11
import java.io.File;
12
import java.io.IOException;
13
import java.io.InputStream;
14
import java.net.URI;
15
import java.net.URISyntaxException;
16
import java.util.ArrayList;
17
import java.util.Arrays;
18
import java.util.List;
19
import java.util.Scanner;
20
import java.util.regex.Matcher;
21
import java.util.regex.Pattern;
22
import java.util.stream.Collectors;
23

    
24
import org.apache.commons.io.IOUtils;
25
import org.apache.commons.lang3.SystemUtils;
26
import org.apache.log4j.Level;
27
import org.apache.log4j.Logger;
28

    
29
import com.fasterxml.jackson.core.JsonProcessingException;
30
import com.fasterxml.jackson.core.type.TypeReference;
31
import com.fasterxml.jackson.databind.JsonMappingException;
32
import com.fasterxml.jackson.databind.ObjectMapper;
33

    
34
/**
35
 * Java executor for drush (https://www.drush.org/).
36
 *
37
 * <h3>Local usage:</h3> Set the {@link #setDrupalRoot(File) drupal root folder}
38
 * and the {@link #setSiteURI(URI) site uri}.
39
 *
40
 * <h3>Remote usage:</h3> In addition to the above properties you can also
41
 * define an {@link #setSshHost(String) ssh host} and optionally the according
42
 * {@link #setSshUser(String) ssh user} to execute drush on a remote machine.
43
 *
44
 * @author a.kohlbecker
45
 * @since Jul 31, 2020
46
 */
47
public class DrushExecuter {
48

    
49
    public static final Logger logger = Logger.getLogger(DrushExecuter.class);
50

    
51
    private URI siteURI = null;
52

    
53
    private File drupalRoot = null;
54

    
55
    private String sshUser = null;
56

    
57
    private String sshHost = null;
58

    
59
    public DrushExecuter() throws IOException, InterruptedException, DrushExecutionFailure {
60
        findDrushCommand();
61
    }
62

    
63
    /**
64
     * The execution of this command via
65
     * <code>DrushExecuter.execute({@linkplain #version})</code> results in
66
     * a {@code List<String>} return variable with the following elements:
67
     *
68
     * <ol>
69
     * <li>major</li>
70
     * <li>minor</li>
71
     * <li>patch</li>
72
     * <ol>
73
     */
74
    public static DrushCommand version = new DrushCommand(Arrays.asList("--version"),
75
            "Drush Version\\s+:\\s+(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)", null);
76

    
77
    public static DrushCommand help = new DrushCommand(Arrays.asList("help"), null, null);
78

    
79
    public static DrushCommand coreStatus = new DrushCommand(Arrays.asList("core-status"), null, null);
80

    
81
    /**
82
     * Executes {@code drush vget --exact <variable-key>}
83
     * <p>
84
     * The execution of this command via
85
     * <code>DrushExecuter.execute({@linkplain DrushExecuter#variableSet})</code> results in
86
     * a {@code List<String>} return variable with the following elements:
87
     *
88
     * <ol>
89
     * <li>value</li>
90
     * <li>value</li>
91
     * <li>value</li>
92
     * <ol>
93
     */
94
    public static DrushCommand variableGet = new DrushCommand(Arrays.asList("vget", "--exact", "--format=json", "%s"), true);
95

    
96
    /**
97
     * Executes {@code drush vset --exact <variable-key> <variable-value>}
98
     * <p>
99
     * The execution of this command via
100
     * <code>DrushExecuter.execute({@linkplain DrushExecuter#variableSet})</code> will not return any values.
101
     * The command will fail with an {@link DrushExecutionFailure} if setting the variable was not successful.
102
     */
103
    public static DrushCommand variableSet = new DrushCommand(Arrays.asList("--yes", "vset", "%s", "%s"), false);
104

    
105
    /**
106
     * Executes {@code drush vset --exact <variable-key> <variable-value>}
107
     * <p>
108
     * The execution of this command via
109
     * <code>DrushExecuter.execute({@linkplain DrushExecuter#variableSet})</code> will not return any values.
110
     * The command will fail with an {@link DrushExecutionFailure} if setting the variable was not successful.
111
     */
112
    public static DrushCommand variableSetJson = new DrushCommand(Arrays.asList("--yes", "vset", "--exact", "--format=json", "%s", "%s"), false);
113

    
114

    
115
    /**
116
     * @throws IOException
117
     *             if an I/O error occurs in the ProcessBuilder
118
     * @throws InterruptedException
119
     *             if the Process was interrupted
120
     * @throws DrushExecutionFailure
121
     *              if the drush command execution fails with an error code
122
     */
123
    private void findDrushCommand() throws IOException, InterruptedException, DrushExecutionFailure {
124

    
125
        if (SystemUtils.IS_OS_WINDOWS) {
126
            throw new RuntimeException("not yet implmented for Windows");
127
        }
128
        if(DrushCommand.majorVersion == null) {
129
            List<Object> matches = execute(version);
130
            DrushCommand.majorVersion = (String) matches.get(0);
131
            DrushCommand.minorVersion = (String) matches.get(1);
132
            DrushCommand.patchLevel = (String) matches.get(2);
133
        }
134
        if(DrushCommand.majorVersion.isEmpty()) {
135
            throw new RuntimeException("No suitable drush command found in the system");
136
        }
137
        if (Integer.valueOf(DrushCommand.majorVersion) < 8) {
138
            throw new RuntimeException("drush version >= 8 required");
139
        }
140
    }
141

    
142
    public String drushVersion() {
143
        return DrushCommand.majorVersion + "." + DrushCommand.minorVersion + "." + DrushCommand.patchLevel;
144
    }
145

    
146
    /**
147
     * @throws IOException
148
     *             if an I/O error occurs in the ProcessBuilder
149
     * @throws InterruptedException
150
     *             if the Process was interrupted
151
     * @throws DrushExecutionFailure
152
     *              if the drush command execution fails with an error code
153
     */
154
    public List<Object> execute(DrushCommand cmd, String... value) throws IOException, InterruptedException, DrushExecutionFailure {
155

    
156
        List<String> executableWithArgs = new ArrayList<>();
157

    
158
        if (sshHost != null) {
159
            executableWithArgs.add("ssh");
160
            String userHostArg = sshHost;
161
            if (sshUser != null) {
162
                userHostArg = sshUser + "@" + sshHost;
163
            }
164
            executableWithArgs.add(userHostArg);
165
        }
166

    
167
        executableWithArgs.add("drush");
168

    
169
        if (drupalRoot != null) {
170
            executableWithArgs.add("--root=" + drupalRoot.toString());
171
        }
172
        if (siteURI != null) {
173
            executableWithArgs.add("--uri=" + siteURI.toString());
174
        }
175
        int commandSubstitutions = 0;
176
        for (String arg : cmd.args) {
177
            if (arg.contains("%s")) {
178
                executableWithArgs.add(String.format(arg, value[commandSubstitutions]));
179
                commandSubstitutions++;
180
            } else {
181
                executableWithArgs.add(arg);
182
            }
183
        }
184

    
185
        List<Object> matches = new ArrayList<>();
186

    
187
        ProcessBuilder pb = new ProcessBuilder(executableWithArgs);
188
        logger.warn("Command: " + pb.command().toString());
189
        Process process = pb.start();
190
        int exitCode = process.waitFor();
191

    
192
        if (exitCode == 0) {
193
            String out, error;
194
            if(cmd.jsonResult) {
195
                out = readExecutionResponse(matches, process.getInputStream());
196
                error = readExecutionResponse(matches, process.getErrorStream());
197
            } else {
198
                out = readExecutionResponse(matches, process.getInputStream(), cmd.outRegex);
199
                error = readExecutionResponse(matches, process.getErrorStream(), cmd.errRegex);
200
            }
201
            if (out != null && !out.trim().isEmpty()) {
202
                logger.info(out.trim());
203
            }
204
            if (error != null && !error.trim().isEmpty()) {
205
                if(!error.contains("[success]")) {
206
                    logger.info(error);
207
                } else {
208
                    logger.error(error);
209
                }
210
            }
211
        } else {
212
            throw new DrushExecutionFailure(
213
                    executableWithArgs,
214
                    IOUtils.toString(process.getInputStream()),
215
                    IOUtils.toString(process.getErrorStream())
216
                    );
217
        }
218
        return matches;
219
    }
220

    
221
    protected String readExecutionResponse(List<Object> matches, InputStream stream, Pattern regex) throws IOException {
222
        String out;
223
        if (regex != null) {
224
            Scanner scanner = new Scanner(stream);
225
            while (true) {
226
                out = scanner.findWithinHorizon(regex, 0);
227
                if (out == null) {
228
                    break;
229
                }
230
                Matcher m = regex.matcher(out);
231
                int patternMatchCount = 0;
232
                while (m.find()) {
233
                    patternMatchCount++;
234
                    if (m.groupCount() > 0) {
235
                        for (int g = 1; g <= m.groupCount(); g++) {
236
                            matches.add(m.group(g));
237
                            logger.debug("match[" + patternMatchCount + "." + g + "]: " + m.group(g));
238
                        }
239
                    } else {
240
                        matches.add(m.group(0));
241
                        logger.debug("entire pattern match[" + patternMatchCount + ".0]: " + m.group(0));
242
                    }
243
                }
244
            }
245
            scanner.close();
246
            return null;
247
        } else {
248
            out = IOUtils.toString(stream);
249
            logger.debug(out);
250
            return out;
251
        }
252
    }
253

    
254
    /**
255
     * @return depending on the drupal variable type different return types are possible:
256
     *  <ul>
257
     *  <li>Object</li>
258
     *  <li>List</li>
259
     *  <li>List</li>
260
     *  <li>String</li>
261
     *  <li>Double</li>
262
     *  <li>Integer</li>
263
     *  </ul>
264
     */
265
    protected String readExecutionResponse(List<Object> matches, InputStream stream) throws IOException {
266
        String out = IOUtils.toString(stream);
267
        try {
268
            if(out != null) {
269
                out = out.trim();
270
                if(!out.isEmpty()) {
271
                    ObjectMapper mapper = new ObjectMapper();
272
                    if(out.startsWith("[")) {
273
                       List<Object> value = mapper.readValue(out, new TypeReference<List<Object>>(){});
274
                       matches.add(value);
275
                    } else  {
276
                       matches.add(mapper.readValue(out, Object.class));
277
                    }
278
                    if(matches.isEmpty()) {
279
                        logger.debug("no result");
280
                    } else {
281
                        logger.debug("result object: " + matches.get(0));
282
                    }
283
                }
284

    
285
            }
286
            return out;
287
        } catch (JsonMappingException  e) {
288
            logger.warn("JsonMappingException for out='"+out+"';" + e.getMessage());
289
            e.printStackTrace();
290
            throw e;
291
        } catch (JsonProcessingException e) {
292
            logger.warn("JsonProcessingException for out='"+out+"';" + e.getMessage());
293
            e.printStackTrace();
294
            throw e;
295
        }
296
    }
297

    
298
    public static class DrushCommand {
299

    
300
        private static String majorVersion;
301
        private static String minorVersion;
302
        private static String patchLevel;
303
        Pattern outRegex;
304
        Pattern errRegex;
305
        boolean jsonResult = false;
306
        boolean failOnError = false;
307
        List<String> args = new ArrayList<>();
308

    
309
        /**
310
         * For drush commands not supporting output formatting.
311
         *
312
         * @param args
313
         *            the command arguments
314
         * @param outRegex
315
         *            Regular expression to parse the error stream, capture
316
         *            groups will be put into the <code>List</code> of strings
317
         *            returned by
318
         *            {@link DrushExecuter#execute(DrushCommand, String...)}
319
         * @param errRegex
320
         *            Regular expression to parse the error stream, capture
321
         *            groups will be put into the <code>List</code> of strings
322
         *            returned by
323
         *            {@link DrushExecuter#execute(DrushCommand, String...)}
324
         */
325
        public DrushCommand(List<String> args, String outRegex, String errRegex) {
326
            this.args = args;
327
            if (outRegex != null) {
328
                this.outRegex = Pattern.compile(outRegex, Pattern.MULTILINE);
329
            }
330
            if (errRegex != null) {
331
                this.errRegex = Pattern.compile(errRegex, Pattern.MULTILINE);
332
            }
333
        }
334

    
335
        /**
336
         * For drush commands which don't require return value parsing by regex or
337
         * which support the {@code --format=json} option to return structured data.
338
         *
339
         * @param args
340
         *            the command arguments
341
         */
342
        public DrushCommand(List<String> args, boolean jsonResult) {
343
            this.args = args;
344
            this.jsonResult = jsonResult;
345
        }
346

    
347
        public String commandLineString() {
348
            return args.stream().collect(Collectors.joining(" "));
349
        }
350

    
351
    }
352

    
353
    /**
354
     * These tests have not been implemented as jUnit tests since the execution
355
     * it too much dependent from the local environment. Once the
356
     * <code>DrushExecuter</code> is being used in the selenium test suite will
357
     * be tested implicitly anyway.
358
     *
359
     * @throws DrushExecutionFailure
360
     *              if the drush command execution fails with an error code
361
     */
362
    public static void main(String[] args) throws URISyntaxException, DrushExecutionFailure {
363
        DrushExecuter.logger.setLevel(Level.DEBUG);
364
        try {
365
            DrushExecuter dex = new DrushExecuter();
366
            List<Object> results;
367
            dex.setDrupalRoot(new File("/home/andreas/workspaces/www/drupal-7"));
368
            dex.setSiteURI(new URI("http://edit.test/d7/caryophyllales/"));
369
//            dex.execute(coreStatus);
370
//            dex.execute(help);
371
            results = dex.execute(variableSet, "cdm_webservice_url",
372
                    "http://api.cybertaxonomy.org/cyprus/");
373
            results = dex.execute(variableGet, "cdm_webservice_url");
374
            if (!results.get(0).equals("http://api.cybertaxonomy.org/cyprus/")) {
375
                throw new RuntimeException("unexpected result item 0: " + results.get(0));
376
            }
377
            // test for command failure:
378
            DrushExecutionFailure expectedFailure = null;
379
            try {
380
                dex.setDrupalRoot(new File("/home/andreas/workspaces/www/invalid-folder"));
381
                results = dex.execute(variableGet, "cdm_webservice_url");
382
            } catch(DrushExecutionFailure e) {
383
                expectedFailure = e;
384
            }
385
            if(expectedFailure == null) {
386
                throw new AssertionError("DrushExecutionFailure expected due to command failure");
387
            } else {
388
                logger.debug("invalid command has failed as expected");
389
            }
390
            // testing remote execution via ssh
391
            dex.sshHost = "edit-int";
392
            dex.setDrupalRoot(new File("/var/www/drupal-7-cdm-dataportal/web"));
393
            dex.setSiteURI(new URI("http://int.e-taxonomy.eu/dataportal/integration/cyprus"));
394
            results = dex.execute(variableGet, "cdm_webservice_url");
395
            if (!results.get(0).equals("http://int.e-taxonomy.eu/cdmserver/integration_cyprus/")) {
396
                throw new RuntimeException("unexpected result item 0: " + results.get(0));
397
            }
398

    
399
        } catch (IOException | InterruptedException | AssertionError e) {
400
            e.printStackTrace();
401
        }
402
    }
403

    
404
    public URI getSiteURI() {
405
        return siteURI;
406
    }
407

    
408
    public void setSiteURI(URI siteURI) {
409
        this.siteURI = siteURI;
410
    }
411

    
412
    public File getDrupalRoot() {
413
        return drupalRoot;
414
    }
415

    
416
    public void setDrupalRoot(File drupalRoot) {
417
        this.drupalRoot = drupalRoot;
418
    }
419

    
420
    public String getSshUser() {
421
        return sshUser;
422
    }
423

    
424
    public void setSshUser(String sshUser) {
425
        this.sshUser = sshUser;
426
    }
427

    
428
    public String getSshHost() {
429
        return sshHost;
430
    }
431

    
432
    public void setSshHost(String sshHost) {
433
        this.sshHost = sshHost;
434
    }
435
}
(1-1/2)