choria-mcorpc-support 2.21.1 → 2.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +58 -55
  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