Project

General

Profile

Download (15.7 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.ObjectMapper;
32

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

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

    
50
    private URI siteURI = null;
51

    
52
    private File drupalRoot = null;
53

    
54
    private String sshUser = null;
55

    
56
    private String sshHost = null;
57

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

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

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

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

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

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

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

    
113

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

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

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

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

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

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

    
166
        executableWithArgs.add("drush");
167

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

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

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

    
191
        if (exitCode == 0) {
192
            String out, error;
193
            if(cmd.jsonResult) {
194
                out = readExecutionResponse(matches, process.getInputStream());
195
                //Note AM: this originally also called the json version of readExecutionResponse (with 2 params only), but this suddenly did throw an exception as the error result was no json. Probably this started with a Linux update?
196
                error = readExecutionResponse(matches, process.getErrorStream(), null);
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
                       matches.add(mapper.readValue(out, new TypeReference<List<Object>>(){}));
274
                    } else  {
275
                       matches.add(mapper.readValue(out, Object.class));
276
                    }
277
                    if(matches.isEmpty()) {
278
                        logger.debug("no result");
279
                    } else {
280
                        logger.debug("result object: " + matches.get(0));
281
                    }
282
                }
283

    
284
            }
285
            return out;
286
        } catch (JsonProcessingException  e) {
287
            logger.warn(e.getClass().getSimpleName()+ " for out = '"+out+"';" + e.getMessage());
288
            e.printStackTrace();
289
            throw e;
290
        }
291
    }
292

    
293
    public static class DrushCommand {
294

    
295
        private static String majorVersion;
296
        private static String minorVersion;
297
        private static String patchLevel;
298
        Pattern outRegex;
299
        Pattern errRegex;
300
        boolean jsonResult = false;
301
        boolean failOnError = false;
302
        List<String> args = new ArrayList<>();
303

    
304
        /**
305
         * For drush commands not supporting output formatting.
306
         *
307
         * @param args
308
         *            the command arguments
309
         * @param outRegex
310
         *            Regular expression to parse the error stream, capture
311
         *            groups will be put into the <code>List</code> of strings
312
         *            returned by
313
         *            {@link DrushExecuter#execute(DrushCommand, String...)}
314
         * @param errRegex
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
         */
320
        public DrushCommand(List<String> args, String outRegex, String errRegex) {
321
            this.args = args;
322
            if (outRegex != null) {
323
                this.outRegex = Pattern.compile(outRegex, Pattern.MULTILINE);
324
            }
325
            if (errRegex != null) {
326
                this.errRegex = Pattern.compile(errRegex, Pattern.MULTILINE);
327
            }
328
        }
329

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

    
342
        public String commandLineString() {
343
            return args.stream().collect(Collectors.joining(" "));
344
        }
345

    
346
    }
347

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

    
394
        } catch (IOException | InterruptedException | AssertionError e) {
395
            e.printStackTrace();
396
        }
397
    }
398

    
399
    public URI getSiteURI() {
400
        return siteURI;
401
    }
402

    
403
    public void setSiteURI(URI siteURI) {
404
        this.siteURI = siteURI;
405
    }
406

    
407
    public File getDrupalRoot() {
408
        return drupalRoot;
409
    }
410

    
411
    public void setDrupalRoot(File drupalRoot) {
412
        this.drupalRoot = drupalRoot;
413
    }
414

    
415
    public String getSshUser() {
416
        return sshUser;
417
    }
418

    
419
    public void setSshUser(String sshUser) {
420
        this.sshUser = sshUser;
421
    }
422

    
423
    public String getSshHost() {
424
        return sshHost;
425
    }
426

    
427
    public void setSshHost(String sshHost) {
428
        this.sshHost = sshHost;
429
    }
430
}
(1-1/2)