1 /**
2     Convenience wrappers for executing subprocesses.
3 
4     Copyright: © 2019 Arne Ludwig <arne.ludwig@posteo.de>
5     License: Subject to the terms of the MIT license, as written in the
6              included LICENSE file.
7     Authors: Arne Ludwig <arne.ludwig@posteo.de>
8 */
9 module dalicious.process;
10 
11 
12 import std.algorithm :
13     endsWith,
14     filter;
15 import std.array : array;
16 import std.process :
17     kill,
18     Redirect,
19     Config,
20     pipeProcess,
21     pipeShell,
22     ProcessPipes,
23     wait;
24 import std.range.primitives;
25 import std.traits : isSomeString;
26 import vibe.data.json : toJson = serializeToJson;
27 
28 import std.range.primitives :
29     ElementType,
30     isInputRange;
31 import std.traits : isSomeString;
32 import std.typecons : Flag, Yes;
33 
34 
35 import dalicious.log : LogLevel;
36 
37 /**
38     Execute command and return the output. Logs execution and throws an
39     exception on failure.
40 
41     Params:
42         command  = A range that is first filtered for non-null values. The
43                    zeroth element of the resulting range is the program and
44                    any remaining elements are the command-line arguments.
45         workdir  = The working directory for the new process. By default the
46                    child process inherits the parent's working directory.
47         logLevel = Log level to log execution on.
48     Returns:
49         Output of command.
50     Throws:
51         std.process.ProcessException on command failure
52 */
53 string executeCommand(Range)(
54     Range command,
55     const string workdir = null,
56     LogLevel logLevel = LogLevel.diagnostic,
57 )
58         if (isInputRange!Range && isSomeString!(ElementType!Range))
59 {
60     import std.process : Config, execute;
61 
62     string output = command.executeWrapper!("command",
63             sCmd => execute(sCmd, null, // env
64                 Config.none, size_t.max, workdir))(logLevel);
65     return output;
66 }
67 
68 ///
69 unittest
70 {
71     auto greeting = executeCommand(["echo", "hello", "world"]);
72 
73     assert(greeting == "hello world\n");
74 }
75 
76 
77 /**
78     Execute shellCommand and return the output. Logs execution on
79     LogLevel.diagnostic and throws an exception on failure.
80 
81     Params:
82         shellCommand  = A range command that first filtered for non-null
83                         values, then joined by spaces and then passed verbatim
84                         to the shell.
85         workdir       = The working directory for the new process. By default
86                         the child process inherits the parent's
87                         working directory.
88         logLevel      = Log level to log execution on.
89     Returns:
90         Output of command.
91     Throws:
92         std.process.ProcessException on command failure
93 */
94 string executeShell(Range)(
95     Range shellCommand,
96     const string workdir = null,
97     LogLevel logLevel = LogLevel.diagnostic,
98 )
99         if (isInputRange!Range && isSomeString!(ElementType!Range))
100 {
101     import std.algorithm : joiner;
102     import std.conv : to;
103     import std.process : Config, executeShell;
104 
105     string output = shellCommand.executeWrapper!("shell",
106             sCmd => executeShell(sCmd.joiner(" ").to!string, null, // env
107                 Config.none, size_t.max, workdir))(logLevel);
108 
109     return output;
110 }
111 
112 ///
113 unittest
114 {
115     auto greeting = executeShell(["echo", "hello", "world", "|", "rev"]);
116 
117     assert(greeting == "dlrow olleh\n");
118 }
119 
120 
121 /**
122     Execute script and return the output. Logs execution on
123     LogLevel.diagnostic and throws an exception on failure.
124 
125     Params:
126         script   = A range command that first filtered for non-null values and
127                    escaped by std.process.escapeShellCommand. The output of
128                    this script is piped to a shell in
129                    [Unofficial Bash Strict Mode][ubsc], ie `sh -seu o pipefail`.
130         workdir  = The working directory for the new process. By default the
131                    child process inherits the parent's working directory.
132         logLevel = Log level to log execution on.
133     Returns:
134         Output of command.
135     Throws:
136         std.process.ProcessException on command failure
137 
138     [ubsc]: http://redsymbol.net/articles/unofficial-bash-strict-mode/
139 */
140 string executeScript(Range)(
141     Range script,
142     const string workdir = null,
143     LogLevel logLevel = LogLevel.diagnostic,
144 )
145         if (isInputRange!Range && isSomeString!(ElementType!Range))
146 {
147     import std.process : Config, executeShell;
148 
149     string output = script.executeWrapper!("script",
150             sCmd => executeShell(sCmd.buildScriptLine, null, // env
151                 Config.none, size_t.max, workdir))(logLevel);
152 
153     return output;
154 }
155 
156 ///
157 unittest
158 {
159     auto greeting = executeScript(["echo", "echo", "rock", "&&", "echo", "roll"]);
160 
161     assert(greeting == "rock\nroll\n");
162 }
163 
164 private string executeWrapper(string type, alias execCall, Range)(Range command, LogLevel logLevel)
165         if (isInputRange!Range && isSomeString!(ElementType!Range))
166 {
167     import dalicious.log : logJson;
168     import std.array : array;
169     import std.algorithm :
170         filter,
171         map,
172         min;
173     import std.format : format;
174     import std.process : ProcessException;
175     import std.string : lineSplitter;
176     import vibe.data.json : Json;
177 
178     auto sanitizedCommand = command.filter!"a != null".array;
179 
180     logJson(
181         logLevel,
182         "action", "execute",
183         "type", type,
184         "command", sanitizedCommand.map!Json.array,
185         "state", "pre",
186     );
187     auto result = execCall(sanitizedCommand);
188     logJson(
189         logLevel,
190         "action", "execute",
191         "type", type,
192         "command", sanitizedCommand.map!Json.array,
193         "output", result
194             .output[0 .. min(1024, $)]
195             .lineSplitter
196             .map!Json
197             .array,
198         "exitStatus", result.status,
199         "state", "post",
200     );
201     if (result.status > 0)
202     {
203         throw new ProcessException(
204                 format("process %s returned with non-zero exit code %d: %s",
205                 sanitizedCommand[0], result.status, result.output));
206     }
207 
208     return result.output;
209 }
210 
211 private string buildScriptLine(in string[] command)
212 {
213     import std.process : escapeShellCommand;
214 
215     return escapeShellCommand(command) ~ " | sh -seu o pipefail";
216 }
217 
218 
219 /**
220     Run command and returns an input range of the output lines.
221 */
222 auto pipeLines(Range)(Range command, in string workdir = null)
223         if (isInputRange!Range && isSomeString!(ElementType!Range))
224 {
225     auto sanitizedCommand = command.filter!"a != null".array;
226 
227     return new LinesPipe!ProcessInfo(ProcessInfo(sanitizedCommand, workdir));
228 }
229 
230 /// ditto
231 auto pipeLines(in string shellCommand, in string workdir = null)
232 {
233     return new LinesPipe!ShellInfo(ShellInfo(shellCommand, workdir));
234 }
235 
236 unittest
237 {
238     import std.algorithm : equal;
239     import std.range : only, take;
240 
241     auto cheers = pipeLines("yes 'Cheers!'");
242     assert(cheers.take(5).equal([
243         "Cheers!",
244         "Cheers!",
245         "Cheers!",
246         "Cheers!",
247         "Cheers!",
248     ]));
249 
250     auto helloWorld = pipeLines(only("echo", "Hello World!"));
251     assert(helloWorld.equal(["Hello World!"]));
252 }
253 
254 private struct ProcessInfo
255 {
256     const(string[]) command;
257     const(string) workdir;
258 }
259 
260 private struct ShellInfo
261 {
262     const(string) command;
263     const(string) workdir;
264 }
265 
266 private static final class LinesPipe(CommandInfo)
267 {
268     static enum lineTerminator = "\n";
269 
270     private CommandInfo processInfo;
271     private ProcessPipes process;
272     private string currentLine;
273 
274     this(CommandInfo processInfo)
275     {
276         this.processInfo = processInfo;
277     }
278 
279     ~this()
280     {
281         if (!(process.pid is null))
282             releaseProcess();
283     }
284 
285     void releaseProcess()
286     {
287         if (!process.stdout.isOpen)
288             return;
289 
290         process.stdout.close();
291 
292         version (Posix)
293         {
294             import core.sys.posix.signal : SIGKILL;
295 
296             process.pid.kill(SIGKILL);
297         }
298         else
299         {
300             static assert(0, "Only intended for use on POSIX compliant OS.");
301         }
302 
303         process.pid.wait();
304     }
305 
306     private void ensureInitialized()
307     {
308         if (!(process.pid is null))
309             return;
310 
311         process = launchProcess();
312 
313         if (!empty)
314             popFront();
315     }
316 
317     static if (is(CommandInfo == ProcessInfo))
318         ProcessPipes launchProcess()
319         {
320             return pipeProcess(
321                 processInfo.command,
322                 Redirect.stdout,
323                 null,
324                 Config.none,
325                 processInfo.workdir,
326             );
327         }
328     else static if (is(CommandInfo == ShellInfo))
329         ProcessPipes launchProcess()
330         {
331             return pipeShell(
332                 processInfo.command,
333                 Redirect.stdout,
334                 null,
335                 Config.none,
336                 processInfo.workdir,
337             );
338         }
339 
340     void popFront()
341     {
342         ensureInitialized();
343         assert(!empty, "Attempting to popFront an empty LinesPipe");
344         currentLine = process.stdout.readln();
345 
346         if (currentLine.empty)
347         {
348             currentLine = null;
349             releaseProcess();
350         }
351 
352         if (currentLine.endsWith(lineTerminator))
353             currentLine = currentLine[0 .. $ - lineTerminator.length];
354     }
355 
356     @property string front()
357     {
358         ensureInitialized();
359         assert(!empty, "Attempting to fetch the front of an empty LinesPipe");
360 
361         return currentLine;
362     }
363 
364     @property bool empty()
365     {
366         ensureInitialized();
367 
368         if (!process.stdout.isOpen || process.stdout.eof)
369         {
370             releaseProcess();
371 
372             return true;
373         }
374         else
375         {
376             return false;
377         }
378     }
379 }
380 
381 
382 /**
383     Returns true iff `name` can be executed via the process function in
384     `std.process`. By default, `PATH` will be searched if `name` does not
385     contain directory separators.
386 
387     Params:
388         name       = Path to file or name of executable
389         searchPath = Determines wether or not the path should be searched.
390 */
391 version (Posix) bool isExecutable(scope string name, Flag!"searchPath" searchPath = Yes.searchPath)
392 {
393     // Implementation is analogous to logic in `std.process.spawnProcessImpl`.
394     import std.algorithm : any;
395     import std.path : isDirSeparator;
396 
397     if (!searchPath || any!isDirSeparator(name))
398         return isExecutableFile(name);
399     else
400         return searchPathFor(name) !is null;
401 }
402 
403 
404 version (Posix) private bool isExecutableFile(scope string path) nothrow
405 {
406     // Implementation is analogous to private function `std.process.isExecutable`.
407     import core.sys.posix.unistd : access, X_OK;
408     import std.string : toStringz;
409 
410     return (access(path.toStringz(), X_OK) == 0);
411 }
412 
413 
414 version (Posix) private string searchPathFor(scope string executable)
415 {
416     // Implementation is analogous to private function `std.process.searchPathFor`.
417     import std.algorithm.iteration : splitter;
418     import std.conv : to;
419     import std.path : buildPath;
420     static import core.stdc.stdlib;
421 
422     auto pathz = core.stdc.stdlib.getenv("PATH");
423     if (pathz == null)  return null;
424 
425     foreach (dir; splitter(to!string(pathz), ':'))
426     {
427         auto execPath = buildPath(dir, executable);
428 
429         if (isExecutableFile(execPath))
430             return execPath;
431     }
432 
433     return null;
434 }