choria-mcorpc-support 2.22.0 → 2.23.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mcollective.rb +1 -2
  3. data/lib/mcollective/agent/bolt_tasks.ddl +253 -0
  4. data/lib/mcollective/agent/bolt_tasks.json +365 -0
  5. data/lib/mcollective/agent/bolt_tasks.rb +178 -0
  6. data/lib/mcollective/agent/choria_util.ddl +152 -0
  7. data/lib/mcollective/agent/choria_util.json +244 -0
  8. data/lib/mcollective/agent/rpcutil.ddl +8 -4
  9. data/lib/mcollective/agent/rpcutil.json +333 -0
  10. data/lib/mcollective/agent/scout.ddl +169 -0
  11. data/lib/mcollective/agent/scout.json +224 -0
  12. data/lib/mcollective/agents.rb +7 -6
  13. data/lib/mcollective/aggregate.rb +4 -4
  14. data/lib/mcollective/aggregate/average.rb +2 -2
  15. data/lib/mcollective/aggregate/base.rb +2 -2
  16. data/lib/mcollective/aggregate/result.rb +3 -3
  17. data/lib/mcollective/aggregate/result/collection_result.rb +2 -2
  18. data/lib/mcollective/aggregate/result/numeric_result.rb +2 -2
  19. data/lib/mcollective/aggregate/sum.rb +2 -2
  20. data/lib/mcollective/aggregate/summary.rb +3 -4
  21. data/lib/mcollective/application.rb +57 -21
  22. data/lib/mcollective/application/choria.rb +189 -0
  23. data/lib/mcollective/application/completion.rb +6 -6
  24. data/lib/mcollective/application/facts.rb +11 -11
  25. data/lib/mcollective/application/federation.rb +237 -0
  26. data/lib/mcollective/application/find.rb +4 -4
  27. data/lib/mcollective/application/help.rb +3 -3
  28. data/lib/mcollective/application/inventory.rb +3 -341
  29. data/lib/mcollective/application/ping.rb +5 -51
  30. data/lib/mcollective/application/playbook.rb +207 -0
  31. data/lib/mcollective/application/plugin.rb +106 -106
  32. data/lib/mcollective/application/rpc.rb +3 -108
  33. data/lib/mcollective/application/tasks.rb +425 -0
  34. data/lib/mcollective/applications.rb +11 -10
  35. data/lib/mcollective/audit/choria.rb +33 -0
  36. data/lib/mcollective/cache.rb +2 -4
  37. data/lib/mcollective/client.rb +11 -10
  38. data/lib/mcollective/config.rb +21 -34
  39. data/lib/mcollective/connector/base.rb +2 -1
  40. data/lib/mcollective/connector/nats.ddl +9 -0
  41. data/lib/mcollective/connector/nats.rb +450 -0
  42. data/lib/mcollective/data.rb +8 -3
  43. data/lib/mcollective/data/agent_data.rb +1 -1
  44. data/lib/mcollective/data/base.rb +6 -5
  45. data/lib/mcollective/data/bolt_task_data.ddl +90 -0
  46. data/lib/mcollective/data/bolt_task_data.rb +32 -0
  47. data/lib/mcollective/data/collective_data.rb +1 -1
  48. data/lib/mcollective/data/fact_data.rb +6 -6
  49. data/lib/mcollective/data/fstat_data.rb +2 -4
  50. data/lib/mcollective/data/result.rb +7 -2
  51. data/lib/mcollective/ddl/agentddl.rb +5 -17
  52. data/lib/mcollective/ddl/base.rb +10 -13
  53. data/lib/mcollective/discovery.rb +24 -39
  54. data/lib/mcollective/discovery/choria.ddl +11 -0
  55. data/lib/mcollective/discovery/choria.rb +223 -0
  56. data/lib/mcollective/discovery/flatfile.rb +7 -8
  57. data/lib/mcollective/discovery/mc.rb +2 -2
  58. data/lib/mcollective/discovery/stdin.rb +17 -18
  59. data/lib/mcollective/exceptions.rb +13 -0
  60. data/lib/mcollective/facts/base.rb +9 -9
  61. data/lib/mcollective/facts/yaml_facts.rb +12 -12
  62. data/lib/mcollective/generators.rb +3 -3
  63. data/lib/mcollective/generators/agent_generator.rb +3 -4
  64. data/lib/mcollective/generators/base.rb +14 -15
  65. data/lib/mcollective/generators/data_generator.rb +5 -6
  66. data/lib/mcollective/log.rb +2 -2
  67. data/lib/mcollective/logger/base.rb +3 -2
  68. data/lib/mcollective/logger/console_logger.rb +10 -10
  69. data/lib/mcollective/logger/file_logger.rb +7 -7
  70. data/lib/mcollective/logger/syslog_logger.rb +11 -15
  71. data/lib/mcollective/message.rb +8 -39
  72. data/lib/mcollective/monkey_patches.rb +2 -4
  73. data/lib/mcollective/optionparser.rb +2 -1
  74. data/lib/mcollective/pluginmanager.rb +3 -5
  75. data/lib/mcollective/pluginpackager.rb +1 -3
  76. data/lib/mcollective/pluginpackager/agent_definition.rb +3 -8
  77. data/lib/mcollective/pluginpackager/forge_packager.rb +7 -9
  78. data/lib/mcollective/pluginpackager/standard_definition.rb +1 -2
  79. data/lib/mcollective/registration/base.rb +18 -16
  80. data/lib/mcollective/rpc.rb +2 -4
  81. data/lib/mcollective/rpc/actionrunner.rb +16 -18
  82. data/lib/mcollective/rpc/agent.rb +26 -43
  83. data/lib/mcollective/rpc/audit.rb +1 -0
  84. data/lib/mcollective/rpc/client.rb +67 -85
  85. data/lib/mcollective/rpc/helpers.rb +55 -62
  86. data/lib/mcollective/rpc/progress.rb +2 -2
  87. data/lib/mcollective/rpc/reply.rb +17 -19
  88. data/lib/mcollective/rpc/request.rb +7 -5
  89. data/lib/mcollective/rpc/result.rb +6 -8
  90. data/lib/mcollective/rpc/stats.rb +49 -58
  91. data/lib/mcollective/security/base.rb +13 -56
  92. data/lib/mcollective/security/choria.rb +765 -0
  93. data/lib/mcollective/shell.rb +9 -4
  94. data/lib/mcollective/signer/base.rb +28 -0
  95. data/lib/mcollective/signer/choria.rb +185 -0
  96. data/lib/mcollective/ssl.rb +8 -6
  97. data/lib/mcollective/util.rb +73 -82
  98. data/lib/mcollective/util/bolt_support.rb +176 -0
  99. data/lib/mcollective/util/bolt_support/plan_runner.rb +167 -0
  100. data/lib/mcollective/util/bolt_support/task_result.rb +94 -0
  101. data/lib/mcollective/util/bolt_support/task_results.rb +128 -0
  102. data/lib/mcollective/util/choria.rb +946 -0
  103. data/lib/mcollective/util/indifferent_hash.rb +12 -0
  104. data/lib/mcollective/util/natswrapper.rb +242 -0
  105. data/lib/mcollective/util/playbook.rb +435 -0
  106. data/lib/mcollective/util/playbook/data_stores.rb +201 -0
  107. data/lib/mcollective/util/playbook/data_stores/base.rb +99 -0
  108. data/lib/mcollective/util/playbook/data_stores/consul_data_store.rb +88 -0
  109. data/lib/mcollective/util/playbook/data_stores/environment_data_store.rb +33 -0
  110. data/lib/mcollective/util/playbook/data_stores/etcd_data_store.rb +42 -0
  111. data/lib/mcollective/util/playbook/data_stores/file_data_store.rb +106 -0
  112. data/lib/mcollective/util/playbook/data_stores/shell_data_store.rb +103 -0
  113. data/lib/mcollective/util/playbook/inputs.rb +265 -0
  114. data/lib/mcollective/util/playbook/nodes.rb +207 -0
  115. data/lib/mcollective/util/playbook/nodes/mcollective_nodes.rb +86 -0
  116. data/lib/mcollective/util/playbook/nodes/pql_nodes.rb +40 -0
  117. data/lib/mcollective/util/playbook/nodes/shell_nodes.rb +55 -0
  118. data/lib/mcollective/util/playbook/nodes/terraform_nodes.rb +65 -0
  119. data/lib/mcollective/util/playbook/nodes/yaml_nodes.rb +47 -0
  120. data/lib/mcollective/util/playbook/playbook_logger.rb +47 -0
  121. data/lib/mcollective/util/playbook/puppet_logger.rb +51 -0
  122. data/lib/mcollective/util/playbook/report.rb +152 -0
  123. data/lib/mcollective/util/playbook/task_result.rb +55 -0
  124. data/lib/mcollective/util/playbook/tasks.rb +196 -0
  125. data/lib/mcollective/util/playbook/tasks/base.rb +45 -0
  126. data/lib/mcollective/util/playbook/tasks/graphite_event_task.rb +64 -0
  127. data/lib/mcollective/util/playbook/tasks/mcollective_task.rb +356 -0
  128. data/lib/mcollective/util/playbook/tasks/shell_task.rb +93 -0
  129. data/lib/mcollective/util/playbook/tasks/slack_task.rb +105 -0
  130. data/lib/mcollective/util/playbook/tasks/webhook_task.rb +136 -0
  131. data/lib/mcollective/util/playbook/template_util.rb +98 -0
  132. data/lib/mcollective/util/playbook/uses.rb +169 -0
  133. data/lib/mcollective/util/tasks_support.rb +751 -0
  134. data/lib/mcollective/util/tasks_support/cli.rb +260 -0
  135. data/lib/mcollective/util/tasks_support/default_formatter.rb +138 -0
  136. data/lib/mcollective/util/tasks_support/json_formatter.rb +108 -0
  137. data/lib/mcollective/validator.rb +6 -1
  138. data/lib/mcollective/validator/bolt_task_name_validator.ddl +7 -0
  139. data/lib/mcollective/validator/bolt_task_name_validator.rb +11 -0
  140. data/lib/mcollective/validator/length_validator.rb +1 -3
  141. metadata +65 -6
  142. data/lib/mcollective/application/describe_filter.rb +0 -87
  143. data/lib/mcollective/matcher.rb +0 -220
  144. data/lib/mcollective/matcher/parser.rb +0 -128
  145. data/lib/mcollective/matcher/scanner.rb +0 -241
@@ -0,0 +1,751 @@
1
+ require "digest"
2
+ require "uri"
3
+ require "tempfile"
4
+
5
+ module MCollective
6
+ module Util
7
+ class TasksSupport
8
+ attr_reader :cache_dir, :choria
9
+
10
+ def initialize(choria, cache_dir=nil)
11
+ @choria = choria
12
+ @cache_dir = cache_dir || @choria.get_option("choria.tasks_cache")
13
+ end
14
+
15
+ # Creates an instance of the CLI helpers
16
+ #
17
+ # @param format [:json, :default] the output format to use
18
+ # @return [CLI]
19
+ def cli(format, verbose)
20
+ require_relative "tasks_support/cli"
21
+ CLI.new(self, format, verbose)
22
+ end
23
+
24
+ # Converts a Puppet type into something mcollective understands
25
+ #
26
+ # This is inevitably hacky by its nature, there is no way for me to
27
+ # parse the types. PAL might get some helpers for this but till then
28
+ # this is going to have to be best efforts.
29
+ #
30
+ # When there is a too complex situation users can always put in --input
31
+ # and some JSON to work around it until something better comes around
32
+ #
33
+ # @param type [String] a puppet type
34
+ # @return [Class, Boolean, Boolean] The data type, if its an array input or not and if its required
35
+ def puppet_type_to_ruby(type)
36
+ array = false
37
+ required = true
38
+
39
+ if type =~ /Optional\[(.+)/
40
+ type = $1
41
+ required = false
42
+ end
43
+
44
+ if type =~ /Array\[(.+)/
45
+ type = $1
46
+ array = true
47
+ end
48
+
49
+ return [Numeric, array, required] if type =~ /Integer/
50
+ return [Numeric, array, required] if type =~ /Float/
51
+ return [Hash, array, required] if type =~ /Hash/
52
+ return [:boolean, array, required] if type =~ /Boolean/
53
+
54
+ [String, array, required]
55
+ end
56
+
57
+ # Determines if a machine is compatible with running bolt
58
+ #
59
+ # @note this should check for a compatible version of Puppet more
60
+ # @return [Boolean]
61
+ def tasks_compatible?
62
+ File.exist?(wrapper_path) && File.executable?(wrapper_path)
63
+ end
64
+
65
+ # AIO path to binaries like wrappers etc
66
+ def aio_bin_path
67
+ if Util.windows?
68
+ 'C:\Program Files\Puppet Labs\Puppet\bin'
69
+ else
70
+ "/opt/puppetlabs/puppet/bin"
71
+ end
72
+ end
73
+
74
+ # Path to the AIO task wrapper executable
75
+ #
76
+ # @return [String]
77
+ def aio_wrapper_path
78
+ if Util.windows?
79
+ legacy = File.join(aio_bin_path, "task_wrapper.exe")
80
+ return legacy if File.exist?(legacy)
81
+
82
+ File.join(aio_bin_path, "execution_wrapper.exe")
83
+ else
84
+ legacy = File.join(aio_bin_path, "task_wrapper")
85
+ return legacy if File.exist?(legacy)
86
+
87
+ File.join(aio_bin_path, "execution_wrapper")
88
+ end
89
+ end
90
+
91
+ # Path to the task wrapper executable
92
+ #
93
+ # @return [String]
94
+ def wrapper_path
95
+ @choria.get_option("choria.tasks.wrapper_path", aio_wrapper_path)
96
+ end
97
+
98
+ # Path to the powershell shim for powershell input method
99
+ #
100
+ # @return [String]
101
+ def ps_shim_path
102
+ File.join(aio_bin_path, "PowershellShim.ps1")
103
+ end
104
+
105
+ # Expands the path into a platform specific version
106
+ #
107
+ # @see https://github.com/puppetlabs/puppet-specifications/tree/730a2aa23e58b93387d194dbac64af508bdeab01/tasks#task-execution
108
+ # @param path [Array<String>] the path to the executable and any arguments
109
+ # @raise [StandardError] when execution of a specific file is not supported
110
+ def platform_specific_command(path)
111
+ return [path] unless Util.windows?
112
+
113
+ extension = File.extname(path)
114
+
115
+ # https://github.com/puppetlabs/pxp-agent/blob/3e7cada3cedf7f78703781d44e70010d0c5ad209/lib/src/modules/task.cc#L98-L107
116
+ case extension
117
+ when ".rb"
118
+ ["ruby", path]
119
+ when ".pp"
120
+ ["puppet", "apply", path]
121
+ when ".ps1"
122
+ ["powershell", "-NoProfile", "-NonInteractive", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File", path]
123
+ else
124
+ [path]
125
+ end
126
+ end
127
+
128
+ # Given a task description checks all files are correctly cached
129
+ #
130
+ # @note this checks all files, though for now there's only ever one file
131
+ # @see #task_file?
132
+ # @param files [Array] files list
133
+ # @return [Boolean]
134
+ def cached?(files)
135
+ files.map {|f| task_file?(f)}.all?
136
+ end
137
+
138
+ # Given a task spec figures out the input method
139
+ #
140
+ # @param task [Hash] task specification
141
+ # @return ["powershell", "both", "stdin", "environment"]
142
+ def task_input_method(task)
143
+ # the spec says only 1 executable, no idea what the point of the 'files' is
144
+ file_extension = File.extname(task["files"][0]["filename"])
145
+
146
+ input_method = task["input_method"]
147
+ input_method = "powershell" if input_method.nil? && file_extension == ".ps1"
148
+ input_method ||= "both"
149
+
150
+ input_method
151
+ end
152
+
153
+ # Given a task spec figures out the command to run using the wrapper
154
+ #
155
+ # @param spooldir [String] path to the spool for this specific request
156
+ # @param task [Hash] task specification
157
+ # @return [String] path to the command
158
+ def task_command(spooldir, task)
159
+ file_spec = task["files"][0]
160
+ file_name = File.join(spooldir, "files", file_spec["filename"])
161
+
162
+ command = platform_specific_command(file_name)
163
+
164
+ command.unshift(ps_shim_path) if task_input_method(task) == "powershell"
165
+
166
+ command
167
+ end
168
+
169
+ # Given a task spec calculates the correct environment hash
170
+ #
171
+ # @param task [Hash] task specification
172
+ # @param task_id [String] task id - usually the mcollective request id
173
+ # @param task_caller [String] the caller invoking the task
174
+ # @return [Hash]
175
+ def task_environment(task, task_id, task_caller)
176
+ environment = {
177
+ "_task" => task["task"],
178
+ "_choria_task_id" => task_id,
179
+ "_choria_task_caller" => task_caller
180
+ }
181
+
182
+ return environment unless task["input"]
183
+ return environment unless ["both", "environment"].include?(task_input_method(task))
184
+
185
+ JSON.parse(task["input"]).each do |k, v|
186
+ environment["PT_%s" % k] = v.to_s
187
+ end
188
+
189
+ environment["PT__installdir"] = File.join(request_spooldir(task_id), "files")
190
+
191
+ environment
192
+ end
193
+
194
+ # Generate the path to the spool for a specific request
195
+ #
196
+ # @param requestid [String] task id
197
+ # @return [String] directory
198
+ def request_spooldir(requestid)
199
+ File.join(choria.tasks_spool_dir, requestid)
200
+ end
201
+
202
+ # Generates the spool path and create it
203
+ #
204
+ # @param requestid [String] unique mco request id
205
+ # @param task [Hash] task specification
206
+ # @return [String] path to the spool dir
207
+ # @raise [StandardError] should it not be able to make the directory
208
+ def create_request_spooldir(requestid, task)
209
+ dir = request_spooldir(requestid)
210
+
211
+ FileUtils.mkdir_p(dir, :mode => 0o0750)
212
+
213
+ populate_spooldir(dir, task)
214
+
215
+ dir
216
+ end
217
+
218
+ # Copy task files to the spool directory
219
+ # @param spooldir [String] path to the spool dir
220
+ # @param task [Hash] task specification
221
+ def populate_spooldir(spooldir, task)
222
+ task["files"].each do |file|
223
+ spool_filename = File.join(spooldir, "files", file["filename"])
224
+
225
+ FileUtils.mkdir_p(File.dirname(spool_filename), :mode => 0o0750)
226
+ FileUtils.cp(task_file_name(file), spool_filename)
227
+ end
228
+ end
229
+
230
+ # Given a task spec, creates the standard input
231
+ #
232
+ # @param task [Hash] task specification
233
+ # @return [Hash, nil]
234
+ def task_input(task)
235
+ task["input"] if ["both", "powershell", "stdin"].include?(task_input_method(task))
236
+ end
237
+
238
+ # Runs the wrapper command detached from mcollective
239
+ #
240
+ # We always detach we have no idea how long these tasks will run
241
+ # since people can do whatever they like, we'll then check them
242
+ # till the agent timeout but if timeout happens they keep running
243
+ #
244
+ # The idea is that UI will in that case present the user with a request
245
+ # id - which is also the spool name - and the user can later come and
246
+ # act on these tasks either by asking for their status or perhaps killing
247
+ # them?
248
+ #
249
+ # @param command [Array<String>] command to run
250
+ # @param environment [Hash] environment to run with
251
+ # @param stdin [String] stdin to send to the command
252
+ # @param spooldir [String] path to the spool for this specific request
253
+ # @param run_as [String] name of the user who will run the command
254
+ # @return [Integer] the pid that was spawned
255
+ def spawn_command(command, environment, stdin, spooldir, run_as)
256
+ wrapper_input = File.join(spooldir, "wrapper_stdin")
257
+ wrapper_stdout = File.join(spooldir, "wrapper_stdout")
258
+ wrapper_stderr = File.join(spooldir, "wrapper_stderr")
259
+ wrapper_pid = File.join(spooldir, "wrapper_pid")
260
+
261
+ options = {
262
+ :chdir => "/",
263
+ :in => :close,
264
+ :out => wrapper_stdout,
265
+ :err => wrapper_stderr
266
+ }
267
+
268
+ if stdin
269
+ File.open(wrapper_input, "w") {|i| i.print(stdin) }
270
+ options[:in] = wrapper_input
271
+ end
272
+
273
+ if run_as
274
+ raise("System does not allow forking. run_as not usable.") unless Process.respond_to?(:fork)
275
+
276
+ require "etc"
277
+
278
+ u = Etc.getpwnam(run_as)
279
+
280
+ FileUtils.chown_R(u.uid, u.gid, spooldir)
281
+
282
+ pid = Process.fork
283
+ if pid.nil?
284
+ Process.gid = Process.egid = u.gid
285
+ Process.uid = Process.euid = u.uid
286
+ Process.exec(environment, command, options)
287
+ end
288
+ else
289
+ pid = Process.spawn(environment, command, options)
290
+ end
291
+
292
+ sleep 0.1 until File.exist?(wrapper_stdout)
293
+
294
+ File.open(wrapper_pid, "w") {|p| p.write(pid)}
295
+
296
+ Process.detach(pid)
297
+
298
+ pid
299
+ end
300
+
301
+ # Determines if a task already ran by checkinf if its spool exist
302
+ #
303
+ # @param requestid [String] request id for the task
304
+ # @return [Boolean]
305
+ def task_ran?(requestid)
306
+ File.directory?(request_spooldir(requestid))
307
+ end
308
+
309
+ # Determines if a task is completed
310
+ #
311
+ # Tasks are run under the wrapper which will write the existcode
312
+ # to a file only after the command have exited, so this will wait
313
+ # for that to appear
314
+ #
315
+ # @param requestid [String] request id for the task
316
+ # @return [Boolean]
317
+ def task_complete?(requestid)
318
+ exitcode = File.join(request_spooldir(requestid), "exitcode")
319
+ wrapper_stderr = File.join(request_spooldir(requestid), "wrapper_stderr")
320
+
321
+ File.exist?(wrapper_stderr) && file_size(wrapper_stderr) > 0 || File.exist?(exitcode) && file_size(exitcode) > 0
322
+ end
323
+
324
+ # Waits for a task to complete
325
+ #
326
+ # @param requestid [String] request id for the task
327
+ def wait_for_task_completion(requestid)
328
+ sleep 0.1 until task_complete?(requestid)
329
+ end
330
+
331
+ # Given a task spec runs it via the Puppet wrappers
332
+ #
333
+ # The task is run in the background and this method waits for it to
334
+ # finish, but should the thread this method runs in be killed the process
335
+ # will continue and one can later check again using the request id
336
+ #
337
+ # @note before this should be run be sure to download the tasks first
338
+ # @param requestid [String] the task requestid
339
+ # @param task [Hash] task specification
340
+ # @param wait [Boolean] should the we wait for the task to complete
341
+ # @param callerid [String] the mcollective callerid who is running the task
342
+ # @return [Hash] the task result as per {#task_result}
343
+ # @raise [StandardError] when calling the wrapper fails etc
344
+ def run_task_command(requestid, task, wait=true, callerid="local")
345
+ raise("The task wrapper %s does not exist, please upgrade Puppet" % wrapper_path) unless File.exist?(wrapper_path)
346
+ raise("Task %s is not available or does not match the specification, please download it" % task["task"]) unless cached?(task["files"])
347
+ raise("Task spool for request %s already exist, cannot rerun", requestid) if task_ran?(requestid)
348
+
349
+ spool = create_request_spooldir(requestid, task)
350
+ command = task_command(spool, task)
351
+
352
+ Log.debug("Trying to spawn task %s in spool %s using command %s" % [task["task"], spool, command])
353
+
354
+ wrapper_input = {
355
+ "executable" => command[0],
356
+ "arguments" => command[1..-1],
357
+ "input" => task_input(task),
358
+ "stdout" => File.join(spool, "stdout"),
359
+ "stderr" => File.join(spool, "stderr"),
360
+ "exitcode" => File.join(spool, "exitcode")
361
+ }
362
+
363
+ File.open(File.join(spool, "choria.json"), "w") do |meta|
364
+ data = {
365
+ "start_time" => Time.now.utc.to_i,
366
+ "caller" => callerid,
367
+ "task" => task["task"],
368
+ "request" => wrapper_input
369
+ }
370
+
371
+ meta.print(data.to_json)
372
+ end
373
+
374
+ pid = spawn_command(wrapper_path, task_environment(task, requestid, callerid), wrapper_input.to_json, spool, task["run_as"])
375
+
376
+ Log.info("Spawned task %s in spool %s with pid %s" % [task["task"], spool, pid])
377
+
378
+ wait_for_task_completion(requestid) if wait
379
+
380
+ task_status(requestid)
381
+ end
382
+
383
+ # Determines how long a task ran for
384
+ #
385
+ # Tasks that had wrapper failures will have a 0 run time, still
386
+ # running tasks will calculate runtime till now and so increase on
387
+ # each invocation
388
+ #
389
+ # @param requestid [String] the request if for the task
390
+ # @return [Float]
391
+ def task_runtime(requestid)
392
+ spool = request_spooldir(requestid)
393
+ wrapper_stderr = File.join(spool, "wrapper_stderr")
394
+ wrapper_pid = File.join(spool, "wrapper_pid")
395
+ exitcode = File.join(spool, "exitcode")
396
+
397
+ if task_complete?(requestid) && File.exist?(exitcode)
398
+ Float(File::Stat.new(exitcode).mtime - File::Stat.new(wrapper_pid).mtime)
399
+ elsif task_complete?(requestid) && file_size(wrapper_stderr) > 0
400
+ 0.0
401
+ else
402
+ Float(Time.now - File::Stat.new(wrapper_pid).mtime)
403
+ end
404
+ end
405
+
406
+ # Parses the stdout and turns it into a JSON object
407
+ #
408
+ # If the output is JSON parsable the output is returned else
409
+ # it's wrapped in _output as per the Tasks spec version 1
410
+ #
411
+ # @note https://github.com/puppetlabs/puppet-specifications/blob/730a2aa23e58b93387d194dbac64af508bdeab01/tasks/README.md#output-handling
412
+ # @param stdout [String] the stdout from the script
413
+ # @param completed [Boolean] if the task is done running
414
+ # @param exitcode [Integer] the exitcode from the script
415
+ # @param wrapper_output [String] the wrapper output
416
+ # @return [Object] the new stdout according to spec and the stdout object, not JSON encoded
417
+ def create_task_stdout(stdout, completed, exitcode, wrapper_output)
418
+ result = {}
419
+
420
+ unless wrapper_output.empty?
421
+ result["_error"] = {
422
+ "kind" => "choria.tasks/wrapper-error",
423
+ "msg" => "The task wrapper failed to run",
424
+ "details" => {
425
+ "wrapper_output" => wrapper_output
426
+ }
427
+ }
428
+
429
+ return result.to_json
430
+ end
431
+
432
+ begin
433
+ data = JSON.parse(stdout)
434
+
435
+ if data.is_a?(Hash)
436
+ result = data
437
+ else
438
+ result["_output"] = stdout
439
+ end
440
+ rescue
441
+ result["_output"] = stdout
442
+ end
443
+
444
+ if exitcode != 0 && completed && !result["_error"]
445
+ result["_error"] = {
446
+ "kind" => "choria.tasks/task-error",
447
+ "msg" => "The task errored with a code %d" % exitcode,
448
+ "details" => {
449
+ "exitcode" => exitcode
450
+ }
451
+ }
452
+ end
453
+
454
+ result
455
+ end
456
+
457
+ # Determines if a task failed based on its status
458
+ #
459
+ # @param status [Hash] the status as produced by {#task_status}
460
+ # @return [Boolean]
461
+ def task_failed?(status)
462
+ return true unless status["wrapper_spawned"]
463
+ return true unless status["wrapper_pid"]
464
+ return true unless status["wrapper_error"].empty?
465
+ return true if status["exitcode"] != 0 && status["completed"]
466
+ return true if status["stdout"].include?("_error")
467
+
468
+ false
469
+ end
470
+
471
+ # Determines the task status for given request
472
+ #
473
+ # @param requestid [String] request id for the task
474
+ # @return [Hash] the task status
475
+ def task_status(requestid)
476
+ raise("Task %s have not been requested" % requestid) unless task_ran?(requestid)
477
+
478
+ spool = request_spooldir(requestid)
479
+ stdout = File.join(spool, "stdout")
480
+ stderr = File.join(spool, "stderr")
481
+ exitcode = File.join(spool, "exitcode")
482
+ wrapper_stderr = File.join(spool, "wrapper_stderr")
483
+ wrapper_pid = File.join(spool, "wrapper_pid")
484
+ meta = File.join(spool, "choria.json")
485
+
486
+ result = {
487
+ "spool" => spool,
488
+ "task" => nil,
489
+ "caller" => nil,
490
+ "stdout" => "",
491
+ "stderr" => "",
492
+ "exitcode" => -1,
493
+ "runtime" => task_runtime(requestid),
494
+ "start_time" => Time.at(0).utc,
495
+ "wrapper_spawned" => false,
496
+ "wrapper_error" => "",
497
+ "wrapper_pid" => nil,
498
+ "completed" => task_complete?(requestid)
499
+ }
500
+
501
+ result["exitcode"] = Integer(File.read(exitcode)) if File.exist?(exitcode)
502
+
503
+ if task_ran?(requestid)
504
+ result["stdout"] = File.read(stdout) if File.exist?(stdout)
505
+ result["stderr"] = File.read(stderr) if File.exist?(stderr)
506
+ result["wrapper_spawned"] = File.exist?(wrapper_stderr) && file_size(wrapper_stderr) == 0
507
+
508
+ result["wrapper_error"] = File.read(wrapper_stderr) if File.exist?(wrapper_stderr) && file_size(wrapper_stderr) > 0
509
+
510
+ if File.exist?(wrapper_pid) && file_size(wrapper_pid) > 0
511
+ result["start_time"] = File::Stat.new(wrapper_pid).mtime.utc
512
+ result["wrapper_pid"] = Integer(File.read(wrapper_pid))
513
+ end
514
+ end
515
+
516
+ if File.exist?(meta)
517
+ choria_metadata = JSON.parse(File.read(meta))
518
+
519
+ result["start_time"] = Time.at(choria_metadata["start_time"]).utc
520
+ result["caller"] = choria_metadata["caller"]
521
+ result["task"] = choria_metadata["task"]
522
+ end
523
+
524
+ result["stdout"] = create_task_stdout(
525
+ result["stdout"],
526
+ result["completed"],
527
+ result["exitcode"],
528
+ result["wrapper_error"]
529
+ )
530
+
531
+ result
532
+ end
533
+
534
+ # Retrieves the list of known tasks in an environment
535
+ #
536
+ # @param environment [String] the environment to query
537
+ # @return [Hash] the v3 task list
538
+ # @raise [StandardError] on http failure
539
+ def tasks(environment)
540
+ resp = http_get("/puppet/v3/tasks?environment=%s" % [environment])
541
+
542
+ raise("Failed to retrieve task list: %s: %s" % [$!.class, $!.to_s]) unless resp.code == "200"
543
+
544
+ tasks = JSON.parse(resp.body)
545
+
546
+ tasks.sort_by {|t| t["name"]}
547
+ end
548
+
549
+ # Retrieves the list of known task names
550
+ #
551
+ # @param environment [String] the environment to query
552
+ # @return [Array<String>] list of task names
553
+ # @raise [StandardError] on http failure
554
+ def task_names(environment)
555
+ tasks(environment).map {|t| t["name"]}
556
+ end
557
+
558
+ # Parse a task name like module::task into it's 2 pieces
559
+ #
560
+ # @param task [String]
561
+ # @return [Array<String>] 2 part array, first the module name then the task name
562
+ # @raise [StandardError] for invalid task names
563
+ def parse_task(task)
564
+ parts = task.split("::")
565
+
566
+ parts << "init" if parts.size == 1
567
+
568
+ parts
569
+ end
570
+
571
+ # Determines the cache path for a task file
572
+ #
573
+ # @param file [Hash] a file hash as per the task metadata
574
+ # @return [String] the directory the file would go into
575
+ def task_file_name(file)
576
+ File.join(cache_dir, file["sha256"])
577
+ end
578
+
579
+ # Does a HTTP GET against the Puppet Server
580
+ #
581
+ # @param path [String] the path to get
582
+ # @param headers [Hash] headers to passs
583
+ # @return [Net::HTTPRequest]
584
+ def http_get(path, headers={}, &blk)
585
+ transport = choria.https(choria.puppet_server, true)
586
+ request = choria.http_get(path)
587
+
588
+ headers.each do |k, v|
589
+ request[k] = v
590
+ end
591
+
592
+ transport.request(request, &blk)
593
+ end
594
+
595
+ # Requests a task metadata from Puppet Server
596
+ #
597
+ # @param task [String] a task name like module::task
598
+ # @param environment [String] the puppet environmnet like production
599
+ # @return [Hash] the metadata according to the v3 spec
600
+ # @raise [StandardError] when the request failed
601
+ def task_metadata(task, environment)
602
+ parsed = parse_task(task)
603
+ path = "/puppet/v3/tasks/%s/%s?environment=%s" % [parsed[0], parsed[1], environment]
604
+
605
+ resp = http_get(path)
606
+
607
+ raise("Failed to request task metadata: %s: %s" % [resp.code, resp.body]) unless resp.code == "200"
608
+
609
+ result = JSON.parse(resp.body)
610
+
611
+ result["metadata"] ||= {}
612
+ result["metadata"]["parameters"] ||= {}
613
+ result["files"] ||= []
614
+
615
+ result
616
+ end
617
+
618
+ # Validates that the inputs would be acceptable to the task
619
+ #
620
+ # @note Copied from PAL TaskSignature#runnable_with?
621
+ # @param inputs [Hash] of keys and values
622
+ # @param task [Hash] task metadata
623
+ # @return [Array[Boolean, Error]]
624
+ def validate_task_inputs(inputs, task)
625
+ return [true, ""] unless task["metadata"]["parameters"]
626
+ return [true, ""] if task["metadata"]["parameters"].empty? && inputs.empty?
627
+
628
+ require "puppet"
629
+
630
+ signature = {}
631
+
632
+ task["metadata"]["parameters"].each do |k, v|
633
+ signature[k] = Puppet::Pops::Types::TypeParser.singleton.parse(v["type"])
634
+ end
635
+
636
+ signature_type = Puppet::Pops::Types::TypeFactory.struct(signature)
637
+
638
+ return [true, ""] if signature_type.instance?(inputs)
639
+
640
+ tm = Puppet::Pops::Types::TypeMismatchDescriber.singleton
641
+ reason = tm.describe_struct_signature(signature_type, inputs).flatten.map(&:format).join("\n")
642
+ reason = "\nInvalid input: \n\t%s" % [reason]
643
+
644
+ [false, reason]
645
+ end
646
+
647
+ # Calculates a hex digest SHA256 for a specific file
648
+ #
649
+ # @param file_path [String] a full path to the file to check
650
+ # @return [String]
651
+ # @raise [StandardError] when the file does not exist
652
+ def file_sha256(file_path)
653
+ Digest::SHA256.file(file_path).hexdigest
654
+ end
655
+
656
+ # Determines the file size of a specific file
657
+ #
658
+ # @param file_path [String] a full path to the file to check
659
+ # @return [Integer] bytes, -1 when the file does not exist
660
+ def file_size(file_path)
661
+ File.stat(file_path).size
662
+ rescue
663
+ -1
664
+ end
665
+
666
+ # Validates a task cache file
667
+ #
668
+ # @param file [Hash] a file hash as per the task metadata
669
+ # @return [Boolean]
670
+ def task_file?(file)
671
+ file_name = task_file_name(file)
672
+
673
+ Log.debug("Checking if file %s is cached using %s" % [file_name, file.pretty_inspect])
674
+
675
+ return false unless File.exist?(file_name)
676
+ return false unless file_size(file_name) == file["size_bytes"]
677
+ return false unless file_sha256(file_name) == file["sha256"]
678
+
679
+ true
680
+ end
681
+
682
+ # Attempts to download and cache the file
683
+ #
684
+ # @note Does not first check if the cache is ok, unconditionally downloads
685
+ # @see #task_file?
686
+ # @param file [Hash] a file hash as per the task metadata
687
+ # @raise [StandardError] when downloading fails
688
+ def cache_task_file(file)
689
+ path = [file["uri"]["path"], URI.encode_www_form(file["uri"]["params"])].join("?")
690
+
691
+ file_name = task_file_name(file)
692
+
693
+ Log.debug("Caching task to %s" % file_name)
694
+
695
+ http_get(path, "Accept" => "application/octet-stream") do |resp|
696
+ raise("Failed to request task content %s: %s: %s" % [path, resp.code, resp.body]) unless resp.code == "200"
697
+
698
+ FileUtils.mkdir_p(cache_dir, :mode => 0o0750)
699
+ FileUtils.rm_rf(file_name) if File.directory?(file_name)
700
+
701
+ task_file = Tempfile.new("tasks_%s" % file["filename"])
702
+ task_file.binmode
703
+
704
+ resp.read_body do |segment|
705
+ task_file.write(segment)
706
+ end
707
+
708
+ task_file.close
709
+
710
+ FileUtils.chmod(0o0750, task_file.path)
711
+ FileUtils.mv(task_file.path, file_name)
712
+ end
713
+ end
714
+
715
+ # Downloads and caches a file set
716
+ #
717
+ # @param files [Array] the files description
718
+ # @return [Boolean] indicating download success
719
+ # @raise [StandardError] on download failures
720
+ def download_files(files)
721
+ Log.info("Downloading %d task files" % files.size)
722
+
723
+ files.each do |file|
724
+ next if task_file?(file)
725
+
726
+ try = 0
727
+
728
+ begin
729
+ return false if try == 2
730
+
731
+ try += 1
732
+
733
+ Log.debug("Downloading task file %s (try %s/2)" % [file["filename"], try])
734
+
735
+ cache_task_file(file)
736
+ rescue
737
+ Log.error(msg = "Could not download task file: %s: %s" % [$!.class, $!.to_s])
738
+
739
+ sleep 0.1
740
+
741
+ retry if try < 2
742
+
743
+ raise(msg)
744
+ end
745
+ end
746
+
747
+ true
748
+ end
749
+ end
750
+ end
751
+ end