1 /** 2 Copyright: Copyright (c) 2019, Joakim Brännström. All rights reserved. 3 License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost Software License 1.0) 4 Author: Joakim Brännström (joakim.brannstrom@gmx.com) 5 */ 6 module distssh.config; 7 8 import core.time : dur, Duration; 9 import logger = std.experimental.logger; 10 import std.algorithm : among, remove, filter, find, map, maxElement; 11 import std.array : array, empty; 12 import std.file : thisExePath, getcwd; 13 import std.format : format; 14 import std.getopt : defaultGetoptPrinter; 15 import std.meta : AliasSeq; 16 import std.path : baseName, buildPath, dirName, absolutePath; 17 import std.range : drop; 18 import std.stdio : writeln, writefln; 19 import std..string : toLower; 20 import std.traits : EnumMembers, hasMember; 21 import std.variant : Algebraic, visit; 22 static import std.getopt; 23 24 import colorlog : VerboseMode; 25 26 import distssh.types; 27 28 version (unittest) { 29 import unit_threaded.assertions; 30 } 31 32 struct Config { 33 struct Global { 34 std.getopt.GetoptResult helpInfo; 35 VerboseMode verbosity; 36 string progName; 37 bool noImportEnv; 38 bool cloneEnv; 39 bool stdinMsgPackEnv; 40 Duration timeout = defaultTimeout_s.dur!"seconds"; 41 42 string selfBinary; 43 string selfDir; 44 45 string importEnv; 46 string workDir; 47 string[] command; 48 49 string dbPath = "distssh.sqlite3"; 50 51 /// The hosts the cluster consist of. 52 Host[] cluster; 53 } 54 55 struct Help { 56 std.getopt.GetoptResult helpInfo; 57 } 58 59 struct Shell { 60 std.getopt.GetoptResult helpInfo; 61 static string helpDescription = "open an interactive shell on the remote host"; 62 } 63 64 struct Cmd { 65 std.getopt.GetoptResult helpInfo; 66 static string helpDescription = "run a command on a remote host"; 67 } 68 69 struct LocalRun { 70 std.getopt.GetoptResult helpInfo; 71 static string helpDescription = "import env and run the command locally"; 72 } 73 74 struct Install { 75 std.getopt.GetoptResult helpInfo; 76 static string helpDescription = "install distssh by setting up the correct symlinks"; 77 } 78 79 struct MeasureHosts { 80 std.getopt.GetoptResult helpInfo; 81 static string helpDescription = "measure the login time and load of all remote hosts"; 82 } 83 84 struct LocalLoad { 85 std.getopt.GetoptResult helpInfo; 86 static string helpDescription = "measure the load on the current host"; 87 } 88 89 struct RunOnAll { 90 std.getopt.GetoptResult helpInfo; 91 static string helpDescription = "run the command on all remote hosts"; 92 } 93 94 struct LocalShell { 95 std.getopt.GetoptResult helpInfo; 96 static string helpDescription = "run the shell locally"; 97 } 98 99 struct Env { 100 std.getopt.GetoptResult helpInfo; 101 static string helpDescription = "manipulate the stored environment"; 102 /// Print the environment. 103 bool print; 104 /// Env variable to set in the config specified in importEnv. 105 string[] envSet; 106 /// Env variables to remove from the onespecified in importEnv. 107 string[] envDel; 108 /// Export the current environment 109 bool exportEnv; 110 } 111 112 struct Daemon { 113 std.getopt.GetoptResult helpInfo; 114 static string helpDescription = "daemon mode"; 115 Duration timeout = 30.dur!"minutes"; 116 } 117 118 alias Type = Algebraic!(Help, Shell, Cmd, LocalRun, Install, MeasureHosts, 119 LocalLoad, RunOnAll, LocalShell, Env, Daemon); 120 Type data; 121 122 Global global; 123 124 void printHelp() { 125 static void printGroup(T)(std.getopt.GetoptResult global, 126 std.getopt.GetoptResult helpInfo, string progName) { 127 const helpDescription = () { 128 static if (hasMember!(T, "helpDescription")) 129 return T.helpDescription ~ "\n"; 130 else 131 return null; 132 }(); 133 defaultGetoptPrinter(format("usage: %s %s <options>\n%s", progName, 134 T.stringof.toLower, helpDescription), global.options); 135 defaultGetoptPrinter(null, helpInfo.options.filter!(a => a.optShort != "-h").array); 136 } 137 138 static void printHelpGroup(std.getopt.GetoptResult helpInfo, string progName) { 139 defaultGetoptPrinter(format("usage: %s <command>\n", progName), helpInfo.options); 140 writeln("sub-command help"); 141 string[2][] subCommands; 142 static foreach (T; Type.AllowedTypes) { 143 static if (hasMember!(T, "helpDescription")) 144 subCommands ~= [T.stringof.toLower, T.helpDescription]; 145 else 146 subCommands ~= [T.stringof.toLower, null]; 147 } 148 const width = subCommands.map!(a => a[0].length).maxElement + 1; 149 foreach (cmd; subCommands) 150 writefln(" %s%*s %s", cmd[0], width - cmd[0].length, " ", cmd[1]); 151 } 152 153 template printers(T...) { 154 static if (T.length == 1) { 155 static if (is(T[0] == Config.Help)) 156 alias printers = (T[0] a) => printHelpGroup(global.helpInfo, global.progName); 157 else 158 alias printers = (T[0] a) => printGroup!(T[0])(global.helpInfo, 159 a.helpInfo, global.progName); 160 } else { 161 alias printers = AliasSeq!(printers!(T[0]), printers!(T[1 .. $])); 162 } 163 } 164 165 data.visit!(printers!(Type.AllowedTypes)); 166 } 167 } 168 169 /** 170 * #SPC-remote_command_parse 171 * 172 * Params: 173 * args = the command line arguments to parse. 174 */ 175 Config parseUserArgs(string[] args) { 176 Config conf; 177 conf.data = Config.Help.init; 178 conf.global.progName = args[0].baseName; 179 conf.global.selfBinary = buildPath(thisExePath.dirName, args[0].baseName); 180 conf.global.selfDir = conf.global.selfBinary.dirName; 181 conf.global.workDir = getcwd; 182 183 switch (conf.global.selfBinary.baseName) { 184 case distShell: 185 conf.data = Config.Shell.init; 186 conf.global.cluster = hostsFromEnv; 187 return conf; 188 case distCmd: 189 if (args.length > 1 && args[1].among("-h", "--help")) 190 conf.data = Config.Help.init; 191 else { 192 conf.data = Config.Cmd.init; 193 conf.global.command = args.length > 1 ? args[1 .. $] : null; 194 conf.global.cluster = hostsFromEnv; 195 configImportEnvFile(conf); 196 } 197 return conf; 198 default: 199 } 200 201 string group; 202 if (args.length > 1 && args[1][0] != '-') { 203 group = args[1]; 204 args = args.remove(1); 205 } 206 207 try { 208 void globalParse() { 209 string export_env_file; 210 ulong timeout_s = defaultTimeout_s; 211 212 // dfmt off 213 conf.global.helpInfo = std.getopt.getopt(args, std.getopt.config.passThrough, std.getopt.config.keepEndOfOptions, 214 "clone-env", "clone the current environment to the remote host without an intermediate file", &conf.global.cloneEnv, 215 "env-file", "file to load the environment from", &export_env_file, 216 "i|import-env", "import the env from the file (default: " ~ distsshEnvExport ~ ")", &conf.global.importEnv, 217 "no-import-env", "do not automatically import the environment from " ~ distsshEnvExport, &conf.global.noImportEnv, 218 "stdin-msgpack-env", "import env from stdin as a msgpack stream", &conf.global.stdinMsgPackEnv, 219 "timeout", "timeout to use when checking remote hosts", &timeout_s, 220 "v|verbose", format("Set the verbosity (%-(%s, %))", [EnumMembers!(VerboseMode)]), &conf.global.verbosity, 221 "workdir", "working directory to run the command in", &conf.global.workDir, 222 ); 223 // dfmt on 224 if (conf.global.helpInfo.helpWanted) 225 args ~= "-h"; 226 227 // must convert e.g. "." 228 conf.global.workDir = conf.global.workDir.absolutePath; 229 230 conf.global.timeout = timeout_s.dur!"seconds"; 231 232 if (!export_env_file.empty) 233 conf.global.importEnv = export_env_file; 234 } 235 236 void helpParse() { 237 conf.data = Config.Help.init; 238 } 239 240 void envParse() { 241 Config.Env data; 242 scope (success) 243 conf.data = data; 244 245 // dfmt off 246 data.helpInfo = std.getopt.getopt(args, std.getopt.config.passThrough, 247 std.getopt.config.keepEndOfOptions, 248 "d|delete", "remove a variable from the exported environment", &data.envDel, 249 "e|export", "export the current environment to a file that is used on the remote host", &data.exportEnv, 250 "p|print", "print the content of an exported environment", &data.print, 251 "s|set", "set a variable in the exported environment. Example: FOO=42", &data.envSet, 252 ); 253 // dfmt on 254 } 255 256 void shellParse() { 257 conf.data = Config.Shell.init; 258 conf.global.cluster = hostsFromEnv; 259 } 260 261 void cmdParse() { 262 conf.data = Config.Cmd.init; 263 conf.global.cluster = hostsFromEnv; 264 } 265 266 void localrunParse() { 267 conf.data = Config.LocalRun.init; 268 } 269 270 void installParse() { 271 conf.data = Config.Install.init; 272 } 273 274 void measurehostsParse() { 275 conf.data = Config.MeasureHosts.init; 276 conf.global.cluster = hostsFromEnv; 277 } 278 279 void localloadParse() { 280 conf.data = Config.LocalLoad.init; 281 } 282 283 void runonallParse() { 284 conf.data = Config.RunOnAll.init; 285 conf.global.cluster = hostsFromEnv; 286 } 287 288 void localshellParse() { 289 conf.data = Config.LocalShell.init; 290 } 291 292 void daemonParse() { 293 conf.data = Config.Daemon.init; 294 conf.global.cluster = hostsFromEnv; 295 } 296 297 alias ParseFn = void delegate(); 298 ParseFn[string] parsers; 299 300 static foreach (T; Config.Type.AllowedTypes) { 301 mixin(format(`parsers["%1$s"] = &%1$sParse;`, T.stringof.toLower)); 302 } 303 304 globalParse; 305 306 if (auto p = group in parsers) { 307 (*p)(); 308 } 309 310 if (args.length > 1) { 311 conf.global.command = args.find("--").drop(1).array(); 312 } 313 configImportEnvFile(conf); 314 } catch (std.getopt.GetOptException e) { 315 // unknown option 316 logger.error(e.msg); 317 } catch (Exception e) { 318 logger.error(e.msg); 319 } 320 321 return conf; 322 } 323 324 /** Update a Configs object's file to import the environment from. 325 * 326 * This should only be called after all other command line parsing has been 327 * done. It is because this function take into consideration the priority as 328 * specified in the requirement: 329 * #SPC-configure_env_import_file 330 * 331 * Params: 332 * opts = config to update the file to import the environment from. 333 */ 334 void configImportEnvFile(ref Config opts) nothrow { 335 import std.process : environment; 336 337 if (opts.global.noImportEnv) { 338 opts.global.importEnv = null; 339 } else if (opts.global.importEnv.length != 0) { 340 // do nothing. the user has specified a file 341 } else { 342 try { 343 opts.global.importEnv = environment.get(globalEnvFileKey, distsshEnvExport); 344 } catch (Exception e) { 345 } 346 } 347 } 348 349 @("shall determine the absolute path of self") 350 unittest { 351 import std.path; 352 import std.file; 353 354 auto opts = parseUserArgs(["distssh", "ls"]); 355 assert(opts.global.selfBinary[0] == '/'); 356 assert(opts.global.selfBinary.baseName == "distssh"); 357 358 opts = parseUserArgs(["distshell"]); 359 assert(opts.global.selfBinary[0] == '/'); 360 assert(opts.global.selfBinary.baseName == "distshell"); 361 362 opts = parseUserArgs(["distcmd"]); 363 assert(opts.global.selfBinary[0] == '/'); 364 assert(opts.global.selfBinary.baseName == "distcmd"); 365 366 opts = parseUserArgs(["distcmd_recv", getcwd, distsshEnvExport]); 367 assert(opts.global.selfBinary[0] == '/'); 368 assert(opts.global.selfBinary.baseName == "distcmd_recv"); 369 } 370 371 @("shall either return the default timeout or the user specified timeout") 372 unittest { 373 import core.time : dur; 374 import std.conv; 375 376 auto opts = parseUserArgs(["distssh", "ls"]); 377 assert(opts.global.timeout == defaultTimeout_s.dur!"seconds"); 378 opts = parseUserArgs(["distssh", "--timeout", "10", "ls"]); 379 assert(opts.global.timeout == 10.dur!"seconds"); 380 381 opts = parseUserArgs(["distshell"]); 382 opts.global.timeout.shouldEqual(defaultTimeout_s.dur!"seconds"); 383 opts = parseUserArgs(["distshell", "--timeout", "10"]); 384 assert(opts.global.timeout == defaultTimeout_s.dur!"seconds"); 385 } 386 387 @("shall only be the default timeout because --timeout should be passed on to the command") 388 unittest { 389 import core.time : dur; 390 import std.conv; 391 392 auto opts = parseUserArgs(["distcmd", "ls"]); 393 assert(opts.global.timeout == defaultTimeout_s.dur!"seconds"); 394 395 opts = parseUserArgs(["distcmd", "--timeout", "10"]); 396 assert(opts.global.timeout == defaultTimeout_s.dur!"seconds"); 397 } 398 399 @("shall convert relative workdirs to absolute when parsing user args") 400 unittest { 401 import std.path : isAbsolute; 402 403 auto opts = parseUserArgs(["distssh", "--workdir", "."]); 404 assert(opts.global.workDir.isAbsolute, "expected an absolute path"); 405 } 406 407 Host[] hostsFromEnv() nothrow { 408 import std.algorithm : splitter, map; 409 import std.array : array; 410 import std.exception : collectException; 411 import std.process : environment; 412 import std..string : strip; 413 414 typeof(return) rval; 415 416 try { 417 string hosts_env = environment.get(globalEnvHostKey, "").strip; 418 rval = hosts_env.splitter(";").map!(a => a.strip) 419 .filter!(a => a.length > 0) 420 .map!(a => Host(a)) 421 .array; 422 423 if (rval.length == 0) { 424 logger.errorf("No remote host configured (%s='%s')", globalEnvHostKey, hosts_env); 425 } 426 } catch (Exception e) { 427 logger.error(e.msg).collectException; 428 } 429 430 return rval; 431 }