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 }