choria-mcorpc-support 2.22.1 → 2.23.3

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 +3 -68
  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 -26
  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 +752 -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,752 @@
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
+ ENV.delete_if { |name| name !~ /^LC_/ }
287
+ Process.exec(environment, command, options)
288
+ end
289
+ else
290
+ pid = Process.spawn(environment, command, options)
291
+ end
292
+
293
+ sleep 0.1 until File.exist?(wrapper_stdout)
294
+
295
+ File.open(wrapper_pid, "w") {|p| p.write(pid)}
296
+
297
+ Process.detach(pid)
298
+
299
+ pid
300
+ end
301
+
302
+ # Determines if a task already ran by checkinf if its spool exist
303
+ #
304
+ # @param requestid [String] request id for the task
305
+ # @return [Boolean]
306
+ def task_ran?(requestid)
307
+ File.directory?(request_spooldir(requestid))
308
+ end
309
+
310
+ # Determines if a task is completed
311
+ #
312
+ # Tasks are run under the wrapper which will write the existcode
313
+ # to a file only after the command have exited, so this will wait
314
+ # for that to appear
315
+ #
316
+ # @param requestid [String] request id for the task
317
+ # @return [Boolean]
318
+ def task_complete?(requestid)
319
+ exitcode = File.join(request_spooldir(requestid), "exitcode")
320
+ wrapper_stderr = File.join(request_spooldir(requestid), "wrapper_stderr")
321
+
322
+ File.exist?(wrapper_stderr) && file_size(wrapper_stderr) > 0 || File.exist?(exitcode) && file_size(exitcode) > 0
323
+ end
324
+
325
+ # Waits for a task to complete
326
+ #
327
+ # @param requestid [String] request id for the task
328
+ def wait_for_task_completion(requestid)
329
+ sleep 0.1 until task_complete?(requestid)
330
+ end
331
+
332
+ # Given a task spec runs it via the Puppet wrappers
333
+ #
334
+ # The task is run in the background and this method waits for it to
335
+ # finish, but should the thread this method runs in be killed the process
336
+ # will continue and one can later check again using the request id
337
+ #
338
+ # @note before this should be run be sure to download the tasks first
339
+ # @param requestid [String] the task requestid
340
+ # @param task [Hash] task specification
341
+ # @param wait [Boolean] should the we wait for the task to complete
342
+ # @param callerid [String] the mcollective callerid who is running the task
343
+ # @return [Hash] the task result as per {#task_result}
344
+ # @raise [StandardError] when calling the wrapper fails etc
345
+ def run_task_command(requestid, task, wait=true, callerid="local")
346
+ raise("The task wrapper %s does not exist, please upgrade Puppet" % wrapper_path) unless File.exist?(wrapper_path)
347
+ raise("Task %s is not available or does not match the specification, please download it" % task["task"]) unless cached?(task["files"])
348
+ raise("Task spool for request %s already exist, cannot rerun", requestid) if task_ran?(requestid)
349
+
350
+ spool = create_request_spooldir(requestid, task)
351
+ command = task_command(spool, task)
352
+
353
+ Log.debug("Trying to spawn task %s in spool %s using command %s" % [task["task"], spool, command])
354
+
355
+ wrapper_input = {
356
+ "executable" => command[0],
357
+ "arguments" => command[1..-1],
358
+ "input" => task_input(task),
359
+ "stdout" => File.join(spool, "stdout"),
360
+ "stderr" => File.join(spool, "stderr"),
361
+ "exitcode" => File.join(spool, "exitcode")
362
+ }
363
+
364
+ File.open(File.join(spool, "choria.json"), "w") do |meta|
365
+ data = {
366
+ "start_time" => Time.now.utc.to_i,
367
+ "caller" => callerid,
368
+ "task" => task["task"],
369
+ "request" => wrapper_input
370
+ }
371
+
372
+ meta.print(data.to_json)
373
+ end
374
+
375
+ pid = spawn_command(wrapper_path, task_environment(task, requestid, callerid), wrapper_input.to_json, spool, task["run_as"])
376
+
377
+ Log.info("Spawned task %s in spool %s with pid %s" % [task["task"], spool, pid])
378
+
379
+ wait_for_task_completion(requestid) if wait
380
+
381
+ task_status(requestid)
382
+ end
383
+
384
+ # Determines how long a task ran for
385
+ #
386
+ # Tasks that had wrapper failures will have a 0 run time, still
387
+ # running tasks will calculate runtime till now and so increase on
388
+ # each invocation
389
+ #
390
+ # @param requestid [String] the request if for the task
391
+ # @return [Float]
392
+ def task_runtime(requestid)
393
+ spool = request_spooldir(requestid)
394
+ wrapper_stderr = File.join(spool, "wrapper_stderr")
395
+ wrapper_pid = File.join(spool, "wrapper_pid")
396
+ exitcode = File.join(spool, "exitcode")
397
+
398
+ if task_complete?(requestid) && File.exist?(exitcode)
399
+ Float(File::Stat.new(exitcode).mtime - File::Stat.new(wrapper_pid).mtime)
400
+ elsif task_complete?(requestid) && file_size(wrapper_stderr) > 0
401
+ 0.0
402
+ else
403
+ Float(Time.now - File::Stat.new(wrapper_pid).mtime)
404
+ end
405
+ end
406
+
407
+ # Parses the stdout and turns it into a JSON object
408
+ #
409
+ # If the output is JSON parsable the output is returned else
410
+ # it's wrapped in _output as per the Tasks spec version 1
411
+ #
412
+ # @note https://github.com/puppetlabs/puppet-specifications/blob/730a2aa23e58b93387d194dbac64af508bdeab01/tasks/README.md#output-handling
413
+ # @param stdout [String] the stdout from the script
414
+ # @param completed [Boolean] if the task is done running
415
+ # @param exitcode [Integer] the exitcode from the script
416
+ # @param wrapper_output [String] the wrapper output
417
+ # @return [Object] the new stdout according to spec and the stdout object, not JSON encoded
418
+ def create_task_stdout(stdout, completed, exitcode, wrapper_output)
419
+ result = {}
420
+
421
+ unless wrapper_output.empty?
422
+ result["_error"] = {
423
+ "kind" => "choria.tasks/wrapper-error",
424
+ "msg" => "The task wrapper failed to run",
425
+ "details" => {
426
+ "wrapper_output" => wrapper_output
427
+ }
428
+ }
429
+
430
+ return result.to_json
431
+ end
432
+
433
+ begin
434
+ data = JSON.parse(stdout)
435
+
436
+ if data.is_a?(Hash)
437
+ result = data
438
+ else
439
+ result["_output"] = stdout
440
+ end
441
+ rescue
442
+ result["_output"] = stdout
443
+ end
444
+
445
+ if exitcode != 0 && completed && !result["_error"]
446
+ result["_error"] = {
447
+ "kind" => "choria.tasks/task-error",
448
+ "msg" => "The task errored with a code %d" % exitcode,
449
+ "details" => {
450
+ "exitcode" => exitcode
451
+ }
452
+ }
453
+ end
454
+
455
+ result
456
+ end
457
+
458
+ # Determines if a task failed based on its status
459
+ #
460
+ # @param status [Hash] the status as produced by {#task_status}
461
+ # @return [Boolean]
462
+ def task_failed?(status)
463
+ return true unless status["wrapper_spawned"]
464
+ return true unless status["wrapper_pid"]
465
+ return true unless status["wrapper_error"].empty?
466
+ return true if status["exitcode"] != 0 && status["completed"]
467
+ return true if status["stdout"].include?("_error")
468
+
469
+ false
470
+ end
471
+
472
+ # Determines the task status for given request
473
+ #
474
+ # @param requestid [String] request id for the task
475
+ # @return [Hash] the task status
476
+ def task_status(requestid)
477
+ raise("Task %s have not been requested" % requestid) unless task_ran?(requestid)
478
+
479
+ spool = request_spooldir(requestid)
480
+ stdout = File.join(spool, "stdout")
481
+ stderr = File.join(spool, "stderr")
482
+ exitcode = File.join(spool, "exitcode")
483
+ wrapper_stderr = File.join(spool, "wrapper_stderr")
484
+ wrapper_pid = File.join(spool, "wrapper_pid")
485
+ meta = File.join(spool, "choria.json")
486
+
487
+ result = {
488
+ "spool" => spool,
489
+ "task" => nil,
490
+ "caller" => nil,
491
+ "stdout" => "",
492
+ "stderr" => "",
493
+ "exitcode" => -1,
494
+ "runtime" => task_runtime(requestid),
495
+ "start_time" => Time.at(0).utc,
496
+ "wrapper_spawned" => false,
497
+ "wrapper_error" => "",
498
+ "wrapper_pid" => nil,
499
+ "completed" => task_complete?(requestid)
500
+ }
501
+
502
+ result["exitcode"] = Integer(File.read(exitcode)) if File.exist?(exitcode)
503
+
504
+ if task_ran?(requestid)
505
+ result["stdout"] = File.read(stdout) if File.exist?(stdout)
506
+ result["stderr"] = File.read(stderr) if File.exist?(stderr)
507
+ result["wrapper_spawned"] = File.exist?(wrapper_stderr) && file_size(wrapper_stderr) == 0
508
+
509
+ result["wrapper_error"] = File.read(wrapper_stderr) if File.exist?(wrapper_stderr) && file_size(wrapper_stderr) > 0
510
+
511
+ if File.exist?(wrapper_pid) && file_size(wrapper_pid) > 0
512
+ result["start_time"] = File::Stat.new(wrapper_pid).mtime.utc
513
+ result["wrapper_pid"] = Integer(File.read(wrapper_pid))
514
+ end
515
+ end
516
+
517
+ if File.exist?(meta)
518
+ choria_metadata = JSON.parse(File.read(meta))
519
+
520
+ result["start_time"] = Time.at(choria_metadata["start_time"]).utc
521
+ result["caller"] = choria_metadata["caller"]
522
+ result["task"] = choria_metadata["task"]
523
+ end
524
+
525
+ result["stdout"] = create_task_stdout(
526
+ result["stdout"],
527
+ result["completed"],
528
+ result["exitcode"],
529
+ result["wrapper_error"]
530
+ )
531
+
532
+ result
533
+ end
534
+
535
+ # Retrieves the list of known tasks in an environment
536
+ #
537
+ # @param environment [String] the environment to query
538
+ # @return [Hash] the v3 task list
539
+ # @raise [StandardError] on http failure
540
+ def tasks(environment)
541
+ resp = http_get("/puppet/v3/tasks?environment=%s" % [environment])
542
+
543
+ raise("Failed to retrieve task list: %s: %s" % [$!.class, $!.to_s]) unless resp.code == "200"
544
+
545
+ tasks = JSON.parse(resp.body)
546
+
547
+ tasks.sort_by {|t| t["name"]}
548
+ end
549
+
550
+ # Retrieves the list of known task names
551
+ #
552
+ # @param environment [String] the environment to query
553
+ # @return [Array<String>] list of task names
554
+ # @raise [StandardError] on http failure
555
+ def task_names(environment)
556
+ tasks(environment).map {|t| t["name"]}
557
+ end
558
+
559
+ # Parse a task name like module::task into it's 2 pieces
560
+ #
561
+ # @param task [String]
562
+ # @return [Array<String>] 2 part array, first the module name then the task name
563
+ # @raise [StandardError] for invalid task names
564
+ def parse_task(task)
565
+ parts = task.split("::")
566
+
567
+ parts << "init" if parts.size == 1
568
+
569
+ parts
570
+ end
571
+
572
+ # Determines the cache path for a task file
573
+ #
574
+ # @param file [Hash] a file hash as per the task metadata
575
+ # @return [String] the directory the file would go into
576
+ def task_file_name(file)
577
+ File.join(cache_dir, file["sha256"])
578
+ end
579
+
580
+ # Does a HTTP GET against the Puppet Server
581
+ #
582
+ # @param path [String] the path to get
583
+ # @param headers [Hash] headers to passs
584
+ # @return [Net::HTTPRequest]
585
+ def http_get(path, headers={}, &blk)
586
+ transport = choria.https(choria.puppet_server, true)
587
+ request = choria.http_get(path)
588
+
589
+ headers.each do |k, v|
590
+ request[k] = v
591
+ end
592
+
593
+ transport.request(request, &blk)
594
+ end
595
+
596
+ # Requests a task metadata from Puppet Server
597
+ #
598
+ # @param task [String] a task name like module::task
599
+ # @param environment [String] the puppet environmnet like production
600
+ # @return [Hash] the metadata according to the v3 spec
601
+ # @raise [StandardError] when the request failed
602
+ def task_metadata(task, environment)
603
+ parsed = parse_task(task)
604
+ path = "/puppet/v3/tasks/%s/%s?environment=%s" % [parsed[0], parsed[1], environment]
605
+
606
+ resp = http_get(path)
607
+
608
+ raise("Failed to request task metadata: %s: %s" % [resp.code, resp.body]) unless resp.code == "200"
609
+
610
+ result = JSON.parse(resp.body)
611
+
612
+ result["metadata"] ||= {}
613
+ result["metadata"]["parameters"] ||= {}
614
+ result["files"] ||= []
615
+
616
+ result
617
+ end
618
+
619
+ # Validates that the inputs would be acceptable to the task
620
+ #
621
+ # @note Copied from PAL TaskSignature#runnable_with?
622
+ # @param inputs [Hash] of keys and values
623
+ # @param task [Hash] task metadata
624
+ # @return [Array[Boolean, Error]]
625
+ def validate_task_inputs(inputs, task)
626
+ return [true, ""] unless task["metadata"]["parameters"]
627
+ return [true, ""] if task["metadata"]["parameters"].empty? && inputs.empty?
628
+
629
+ require "puppet"
630
+
631
+ signature = {}
632
+
633
+ task["metadata"]["parameters"].each do |k, v|
634
+ signature[k] = Puppet::Pops::Types::TypeParser.singleton.parse(v["type"])
635
+ end
636
+
637
+ signature_type = Puppet::Pops::Types::TypeFactory.struct(signature)
638
+
639
+ return [true, ""] if signature_type.instance?(inputs)
640
+
641
+ tm = Puppet::Pops::Types::TypeMismatchDescriber.singleton
642
+ reason = tm.describe_struct_signature(signature_type, inputs).flatten.map(&:format).join("\n")
643
+ reason = "\nInvalid input: \n\t%s" % [reason]
644
+
645
+ [false, reason]
646
+ end
647
+
648
+ # Calculates a hex digest SHA256 for a specific file
649
+ #
650
+ # @param file_path [String] a full path to the file to check
651
+ # @return [String]
652
+ # @raise [StandardError] when the file does not exist
653
+ def file_sha256(file_path)
654
+ Digest::SHA256.file(file_path).hexdigest
655
+ end
656
+
657
+ # Determines the file size of a specific file
658
+ #
659
+ # @param file_path [String] a full path to the file to check
660
+ # @return [Integer] bytes, -1 when the file does not exist
661
+ def file_size(file_path)
662
+ File.stat(file_path).size
663
+ rescue
664
+ -1
665
+ end
666
+
667
+ # Validates a task cache file
668
+ #
669
+ # @param file [Hash] a file hash as per the task metadata
670
+ # @return [Boolean]
671
+ def task_file?(file)
672
+ file_name = task_file_name(file)
673
+
674
+ Log.debug("Checking if file %s is cached using %s" % [file_name, file.pretty_inspect])
675
+
676
+ return false unless File.exist?(file_name)
677
+ return false unless file_size(file_name) == file["size_bytes"]
678
+ return false unless file_sha256(file_name) == file["sha256"]
679
+
680
+ true
681
+ end
682
+
683
+ # Attempts to download and cache the file
684
+ #
685
+ # @note Does not first check if the cache is ok, unconditionally downloads
686
+ # @see #task_file?
687
+ # @param file [Hash] a file hash as per the task metadata
688
+ # @raise [StandardError] when downloading fails
689
+ def cache_task_file(file)
690
+ path = [file["uri"]["path"], URI.encode_www_form(file["uri"]["params"])].join("?")
691
+
692
+ file_name = task_file_name(file)
693
+
694
+ Log.debug("Caching task to %s" % file_name)
695
+
696
+ http_get(path, "Accept" => "application/octet-stream") do |resp|
697
+ raise("Failed to request task content %s: %s: %s" % [path, resp.code, resp.body]) unless resp.code == "200"
698
+
699
+ FileUtils.mkdir_p(cache_dir, :mode => 0o0750)
700
+ FileUtils.rm_rf(file_name) if File.directory?(file_name)
701
+
702
+ task_file = Tempfile.new("tasks_%s" % file["filename"])
703
+ task_file.binmode
704
+
705
+ resp.read_body do |segment|
706
+ task_file.write(segment)
707
+ end
708
+
709
+ task_file.close
710
+
711
+ FileUtils.chmod(0o0750, task_file.path)
712
+ FileUtils.mv(task_file.path, file_name)
713
+ end
714
+ end
715
+
716
+ # Downloads and caches a file set
717
+ #
718
+ # @param files [Array] the files description
719
+ # @return [Boolean] indicating download success
720
+ # @raise [StandardError] on download failures
721
+ def download_files(files)
722
+ Log.info("Downloading %d task files" % files.size)
723
+
724
+ files.each do |file|
725
+ next if task_file?(file)
726
+
727
+ try = 0
728
+
729
+ begin
730
+ return false if try == 2
731
+
732
+ try += 1
733
+
734
+ Log.debug("Downloading task file %s (try %s/2)" % [file["filename"], try])
735
+
736
+ cache_task_file(file)
737
+ rescue
738
+ Log.error(msg = "Could not download task file: %s: %s" % [$!.class, $!.to_s])
739
+
740
+ sleep 0.1
741
+
742
+ retry if try < 2
743
+
744
+ raise(msg)
745
+ end
746
+ end
747
+
748
+ true
749
+ end
750
+ end
751
+ end
752
+ end