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 }