cloud-mu 3.1.4 → 3.3.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +5 -1
  3. data/ansible/roles/mu-windows/README.md +33 -0
  4. data/ansible/roles/mu-windows/defaults/main.yml +2 -0
  5. data/ansible/roles/mu-windows/files/LaunchConfig.json +9 -0
  6. data/ansible/roles/mu-windows/files/config.xml +76 -0
  7. data/ansible/roles/mu-windows/handlers/main.yml +2 -0
  8. data/ansible/roles/mu-windows/meta/main.yml +53 -0
  9. data/ansible/roles/mu-windows/tasks/main.yml +36 -0
  10. data/ansible/roles/mu-windows/tests/inventory +2 -0
  11. data/ansible/roles/mu-windows/tests/test.yml +5 -0
  12. data/ansible/roles/mu-windows/vars/main.yml +2 -0
  13. data/bin/mu-adopt +16 -12
  14. data/bin/mu-azure-tests +57 -0
  15. data/bin/mu-cleanup +2 -4
  16. data/bin/mu-configure +52 -0
  17. data/bin/mu-deploy +3 -3
  18. data/bin/mu-findstray-tests +25 -0
  19. data/bin/mu-gen-docs +2 -4
  20. data/bin/mu-load-config.rb +2 -1
  21. data/bin/mu-node-manage +15 -16
  22. data/bin/mu-run-tests +37 -12
  23. data/cloud-mu.gemspec +5 -3
  24. data/cookbooks/mu-activedirectory/resources/domain.rb +4 -4
  25. data/cookbooks/mu-activedirectory/resources/domain_controller.rb +4 -4
  26. data/cookbooks/mu-tools/libraries/helper.rb +1 -1
  27. data/cookbooks/mu-tools/recipes/apply_security.rb +14 -14
  28. data/cookbooks/mu-tools/recipes/aws_api.rb +9 -0
  29. data/cookbooks/mu-tools/recipes/eks.rb +2 -2
  30. data/cookbooks/mu-tools/recipes/selinux.rb +2 -1
  31. data/cookbooks/mu-tools/recipes/windows-client.rb +163 -164
  32. data/cookbooks/mu-tools/resources/windows_users.rb +44 -43
  33. data/extras/clean-stock-amis +25 -19
  34. data/extras/generate-stock-images +1 -0
  35. data/extras/image-generators/AWS/win2k12.yaml +18 -13
  36. data/extras/image-generators/AWS/win2k16.yaml +18 -13
  37. data/extras/image-generators/AWS/win2k19.yaml +21 -0
  38. data/modules/mommacat.ru +1 -1
  39. data/modules/mu.rb +158 -107
  40. data/modules/mu/adoption.rb +386 -59
  41. data/modules/mu/cleanup.rb +214 -303
  42. data/modules/mu/cloud.rb +128 -1632
  43. data/modules/mu/cloud/database.rb +49 -0
  44. data/modules/mu/cloud/dnszone.rb +44 -0
  45. data/modules/mu/cloud/machine_images.rb +212 -0
  46. data/modules/mu/cloud/providers.rb +81 -0
  47. data/modules/mu/cloud/resource_base.rb +926 -0
  48. data/modules/mu/cloud/server.rb +40 -0
  49. data/modules/mu/cloud/server_pool.rb +1 -0
  50. data/modules/mu/cloud/ssh_sessions.rb +228 -0
  51. data/modules/mu/cloud/winrm_sessions.rb +237 -0
  52. data/modules/mu/cloud/wrappers.rb +169 -0
  53. data/modules/mu/config.rb +135 -82
  54. data/modules/mu/config/alarm.rb +2 -6
  55. data/modules/mu/config/bucket.rb +32 -3
  56. data/modules/mu/config/cache_cluster.rb +2 -2
  57. data/modules/mu/config/cdn.rb +100 -0
  58. data/modules/mu/config/collection.rb +1 -1
  59. data/modules/mu/config/container_cluster.rb +7 -2
  60. data/modules/mu/config/database.rb +84 -105
  61. data/modules/mu/config/database.yml +1 -2
  62. data/modules/mu/config/dnszone.rb +5 -4
  63. data/modules/mu/config/doc_helpers.rb +5 -6
  64. data/modules/mu/config/endpoint.rb +2 -1
  65. data/modules/mu/config/firewall_rule.rb +3 -19
  66. data/modules/mu/config/folder.rb +1 -1
  67. data/modules/mu/config/function.rb +17 -8
  68. data/modules/mu/config/group.rb +1 -1
  69. data/modules/mu/config/habitat.rb +1 -1
  70. data/modules/mu/config/job.rb +89 -0
  71. data/modules/mu/config/loadbalancer.rb +57 -11
  72. data/modules/mu/config/log.rb +1 -1
  73. data/modules/mu/config/msg_queue.rb +1 -1
  74. data/modules/mu/config/nosqldb.rb +1 -1
  75. data/modules/mu/config/notifier.rb +8 -19
  76. data/modules/mu/config/ref.rb +92 -14
  77. data/modules/mu/config/role.rb +1 -1
  78. data/modules/mu/config/schema_helpers.rb +38 -37
  79. data/modules/mu/config/search_domain.rb +1 -1
  80. data/modules/mu/config/server.rb +12 -13
  81. data/modules/mu/config/server.yml +1 -0
  82. data/modules/mu/config/server_pool.rb +3 -7
  83. data/modules/mu/config/storage_pool.rb +1 -1
  84. data/modules/mu/config/tail.rb +11 -0
  85. data/modules/mu/config/user.rb +1 -1
  86. data/modules/mu/config/vpc.rb +27 -23
  87. data/modules/mu/config/vpc.yml +0 -1
  88. data/modules/mu/defaults/AWS.yaml +91 -68
  89. data/modules/mu/defaults/Azure.yaml +1 -0
  90. data/modules/mu/defaults/Google.yaml +1 -0
  91. data/modules/mu/deploy.rb +33 -19
  92. data/modules/mu/groomer.rb +16 -1
  93. data/modules/mu/groomers/ansible.rb +123 -21
  94. data/modules/mu/groomers/chef.rb +64 -11
  95. data/modules/mu/logger.rb +120 -144
  96. data/modules/mu/master.rb +97 -4
  97. data/modules/mu/master/ssl.rb +0 -1
  98. data/modules/mu/mommacat.rb +154 -867
  99. data/modules/mu/mommacat/daemon.rb +23 -14
  100. data/modules/mu/mommacat/naming.rb +110 -3
  101. data/modules/mu/mommacat/search.rb +495 -0
  102. data/modules/mu/mommacat/storage.rb +225 -192
  103. data/modules/mu/{clouds → providers}/README.md +1 -1
  104. data/modules/mu/{clouds → providers}/aws.rb +281 -64
  105. data/modules/mu/{clouds → providers}/aws/alarm.rb +3 -3
  106. data/modules/mu/{clouds → providers}/aws/bucket.rb +275 -41
  107. data/modules/mu/{clouds → providers}/aws/cache_cluster.rb +14 -50
  108. data/modules/mu/providers/aws/cdn.rb +782 -0
  109. data/modules/mu/{clouds → providers}/aws/collection.rb +5 -5
  110. data/modules/mu/{clouds → providers}/aws/container_cluster.rb +708 -749
  111. data/modules/mu/providers/aws/database.rb +1744 -0
  112. data/modules/mu/{clouds → providers}/aws/dnszone.rb +75 -57
  113. data/modules/mu/providers/aws/endpoint.rb +1072 -0
  114. data/modules/mu/{clouds → providers}/aws/firewall_rule.rb +212 -242
  115. data/modules/mu/{clouds → providers}/aws/folder.rb +1 -1
  116. data/modules/mu/{clouds → providers}/aws/function.rb +289 -134
  117. data/modules/mu/{clouds → providers}/aws/group.rb +18 -20
  118. data/modules/mu/{clouds → providers}/aws/habitat.rb +3 -3
  119. data/modules/mu/providers/aws/job.rb +466 -0
  120. data/modules/mu/{clouds → providers}/aws/loadbalancer.rb +50 -41
  121. data/modules/mu/{clouds → providers}/aws/log.rb +5 -5
  122. data/modules/mu/{clouds → providers}/aws/msg_queue.rb +14 -11
  123. data/modules/mu/{clouds → providers}/aws/nosqldb.rb +96 -5
  124. data/modules/mu/{clouds → providers}/aws/notifier.rb +135 -63
  125. data/modules/mu/{clouds → providers}/aws/role.rb +94 -57
  126. data/modules/mu/{clouds → providers}/aws/search_domain.rb +173 -42
  127. data/modules/mu/{clouds → providers}/aws/server.rb +782 -1107
  128. data/modules/mu/{clouds → providers}/aws/server_pool.rb +36 -46
  129. data/modules/mu/{clouds → providers}/aws/storage_pool.rb +21 -38
  130. data/modules/mu/{clouds → providers}/aws/user.rb +12 -16
  131. data/modules/mu/{clouds → providers}/aws/userdata/README.md +0 -0
  132. data/modules/mu/{clouds → providers}/aws/userdata/linux.erb +5 -4
  133. data/modules/mu/{clouds → providers}/aws/userdata/windows.erb +2 -1
  134. data/modules/mu/{clouds → providers}/aws/vpc.rb +429 -849
  135. data/modules/mu/providers/aws/vpc_subnet.rb +286 -0
  136. data/modules/mu/{clouds → providers}/azure.rb +13 -0
  137. data/modules/mu/{clouds → providers}/azure/container_cluster.rb +1 -5
  138. data/modules/mu/{clouds → providers}/azure/firewall_rule.rb +8 -1
  139. data/modules/mu/{clouds → providers}/azure/habitat.rb +0 -0
  140. data/modules/mu/{clouds → providers}/azure/loadbalancer.rb +0 -0
  141. data/modules/mu/{clouds → providers}/azure/role.rb +0 -0
  142. data/modules/mu/{clouds → providers}/azure/server.rb +32 -24
  143. data/modules/mu/{clouds → providers}/azure/user.rb +1 -1
  144. data/modules/mu/{clouds → providers}/azure/userdata/README.md +0 -0
  145. data/modules/mu/{clouds → providers}/azure/userdata/linux.erb +0 -0
  146. data/modules/mu/{clouds → providers}/azure/userdata/windows.erb +0 -0
  147. data/modules/mu/{clouds → providers}/azure/vpc.rb +4 -6
  148. data/modules/mu/{clouds → providers}/cloudformation.rb +10 -0
  149. data/modules/mu/{clouds → providers}/cloudformation/alarm.rb +3 -3
  150. data/modules/mu/{clouds → providers}/cloudformation/cache_cluster.rb +3 -3
  151. data/modules/mu/{clouds → providers}/cloudformation/collection.rb +3 -3
  152. data/modules/mu/{clouds → providers}/cloudformation/database.rb +6 -17
  153. data/modules/mu/{clouds → providers}/cloudformation/dnszone.rb +3 -3
  154. data/modules/mu/{clouds → providers}/cloudformation/firewall_rule.rb +3 -3
  155. data/modules/mu/{clouds → providers}/cloudformation/loadbalancer.rb +3 -3
  156. data/modules/mu/{clouds → providers}/cloudformation/log.rb +3 -3
  157. data/modules/mu/{clouds → providers}/cloudformation/server.rb +7 -7
  158. data/modules/mu/{clouds → providers}/cloudformation/server_pool.rb +5 -5
  159. data/modules/mu/{clouds → providers}/cloudformation/vpc.rb +3 -3
  160. data/modules/mu/{clouds → providers}/docker.rb +0 -0
  161. data/modules/mu/{clouds → providers}/google.rb +29 -6
  162. data/modules/mu/{clouds → providers}/google/bucket.rb +5 -5
  163. data/modules/mu/{clouds → providers}/google/container_cluster.rb +59 -37
  164. data/modules/mu/{clouds → providers}/google/database.rb +5 -12
  165. data/modules/mu/{clouds → providers}/google/firewall_rule.rb +5 -5
  166. data/modules/mu/{clouds → providers}/google/folder.rb +5 -9
  167. data/modules/mu/{clouds → providers}/google/function.rb +14 -8
  168. data/modules/mu/{clouds → providers}/google/group.rb +9 -17
  169. data/modules/mu/{clouds → providers}/google/habitat.rb +4 -8
  170. data/modules/mu/{clouds → providers}/google/loadbalancer.rb +5 -5
  171. data/modules/mu/{clouds → providers}/google/role.rb +50 -31
  172. data/modules/mu/{clouds → providers}/google/server.rb +142 -55
  173. data/modules/mu/{clouds → providers}/google/server_pool.rb +14 -14
  174. data/modules/mu/{clouds → providers}/google/user.rb +34 -24
  175. data/modules/mu/{clouds → providers}/google/userdata/README.md +0 -0
  176. data/modules/mu/{clouds → providers}/google/userdata/linux.erb +0 -0
  177. data/modules/mu/{clouds → providers}/google/userdata/windows.erb +0 -0
  178. data/modules/mu/{clouds → providers}/google/vpc.rb +46 -15
  179. data/modules/tests/aws-jobs-functions.yaml +46 -0
  180. data/modules/tests/centos6.yaml +15 -0
  181. data/modules/tests/centos7.yaml +15 -0
  182. data/modules/tests/centos8.yaml +12 -0
  183. data/modules/tests/ecs.yaml +23 -0
  184. data/modules/tests/eks.yaml +1 -1
  185. data/modules/tests/functions/node-function/lambda_function.js +10 -0
  186. data/modules/tests/functions/python-function/lambda_function.py +12 -0
  187. data/modules/tests/includes-and-params.yaml +2 -1
  188. data/modules/tests/microservice_app.yaml +288 -0
  189. data/modules/tests/rds.yaml +108 -0
  190. data/modules/tests/regrooms/rds.yaml +123 -0
  191. data/modules/tests/server-with-scrub-muisms.yaml +2 -1
  192. data/modules/tests/super_complex_bok.yml +2 -2
  193. data/modules/tests/super_simple_bok.yml +3 -5
  194. data/modules/tests/win2k12.yaml +25 -0
  195. data/modules/tests/win2k16.yaml +25 -0
  196. data/modules/tests/win2k19.yaml +25 -0
  197. data/requirements.txt +1 -0
  198. data/spec/mu/clouds/azure_spec.rb +2 -2
  199. metadata +169 -93
  200. data/extras/image-generators/AWS/windows.yaml +0 -18
  201. data/modules/mu/clouds/aws/database.rb +0 -1974
  202. data/modules/mu/clouds/aws/endpoint.rb +0 -596
  203. data/modules/tests/needwork/win2k12.yaml +0 -13
@@ -30,6 +30,21 @@ module MU
30
30
  ["Chef", "Ansible"]
31
31
  end
32
32
 
33
+ # List of known/supported groomers which are installed and appear to be working
34
+ # @return [Array<String>]
35
+ def self.availableGroomers
36
+ available = []
37
+ MU::Groomer.supportedGroomers.each { |groomer|
38
+ begin
39
+ groomerbase = loadGroomer(groomer)
40
+ available << groomer if groomerbase.available?
41
+ rescue LoadError
42
+ end
43
+ }
44
+
45
+ available
46
+ end
47
+
33
48
  # Instance methods that any Groomer plugin must implement
34
49
  def self.requiredMethods
35
50
  [:preClean, :bootstrap, :haveBootstrapped?, :run, :saveDeployData, :getSecret, :saveSecret, :deleteSecret, :reinstall]
@@ -37,7 +52,7 @@ module MU
37
52
 
38
53
  # Class methods that any Groomer plugin must implement
39
54
  def self.requiredClassMethods
40
- [:getSecret, :cleanup, :saveSecret, :deleteSecret]
55
+ [:getSecret, :cleanup, :saveSecret, :deleteSecret, :available?]
41
56
  end
42
57
 
43
58
  class Ansible;
@@ -24,6 +24,10 @@ module MU
24
24
  class NoAnsibleExecError < MuError;
25
25
  end
26
26
 
27
+ # One or more Python dependencies missing
28
+ class AnsibleLibrariesError < MuError;
29
+ end
30
+
27
31
  # Location in which we'll find our Ansible executables. This only applies
28
32
  # to full-grown Mu masters; minimalist gem installs will have to make do
29
33
  # with whatever Ansible executables they can find in $PATH.
@@ -40,6 +44,10 @@ module MU
40
44
  @ansible_path = node.deploy.deploy_dir+"/ansible"
41
45
  @ansible_execs = MU::Groomer::Ansible.ansibleExecDir
42
46
 
47
+ if !MU::Groomer::Ansible.checkPythonDependencies(@server.windows?)
48
+ raise AnsibleLibrariesError, "One or more python dependencies not available"
49
+ end
50
+
43
51
  if !@ansible_execs or @ansible_execs.empty?
44
52
  raise NoAnsibleExecError, "No Ansible executables found in visible paths"
45
53
  end
@@ -54,6 +62,10 @@ module MU
54
62
  installRoles
55
63
  end
56
64
 
65
+ # Are Ansible executables and key libraries present and accounted for?
66
+ def self.available?(windows = false)
67
+ MU::Groomer::Ansible.checkPythonDependencies(windows)
68
+ end
57
69
 
58
70
  # Indicate whether our server has been bootstrapped with Ansible
59
71
  def haveBootstrapped?
@@ -66,6 +78,7 @@ module MU
66
78
  # @param permissions [Boolean]: If true, save the secret under the current active deploy (if any), rather than in the global location for this user
67
79
  # @param deploy_dir [String]: If permissions is +true+, save the secret here
68
80
  def self.saveSecret(vault: nil, item: nil, data: nil, permissions: false, deploy_dir: nil)
81
+
69
82
  if vault.nil? or vault.empty? or item.nil? or item.empty?
70
83
  raise MuError, "Must call saveSecret with vault and item names"
71
84
  end
@@ -73,7 +86,6 @@ module MU
73
86
  raise MuError, "Ansible vault/item names cannot include forward slashes"
74
87
  end
75
88
  pwfile = vaultPasswordFile
76
-
77
89
 
78
90
  dir = if permissions
79
91
  if deploy_dir
@@ -95,8 +107,9 @@ module MU
95
107
  if File.exist?(path)
96
108
  MU.log "Overwriting existing vault #{vault} item #{item}"
97
109
  end
110
+
98
111
  File.open(path, File::CREAT|File::RDWR|File::TRUNC, 0600) { |f|
99
- f.write data
112
+ f.write data.to_yaml
100
113
  }
101
114
 
102
115
  cmd = %Q{#{ansibleExecDir}/ansible-vault encrypt #{path} --vault-password-file #{pwfile}}
@@ -115,14 +128,23 @@ module MU
115
128
  # @param item [String]: The item within the repository to retrieve
116
129
  # @param field [String]: OPTIONAL - A specific field within the item to return.
117
130
  # @return [Hash]
118
- def self.getSecret(vault: nil, item: nil, field: nil)
131
+ def self.getSecret(vault: nil, item: nil, field: nil, deploy_dir: nil)
119
132
  if vault.nil? or vault.empty?
120
133
  raise MuError, "Must call getSecret with at least a vault name"
121
134
  end
122
-
123
135
  pwfile = vaultPasswordFile
124
- dir = secret_dir+"/"+vault
125
- if !Dir.exist?(dir)
136
+
137
+ dir = nil
138
+ try = [secret_dir+"/"+vault]
139
+ try << deploy_dir+"/ansible/vaults/"+vault if deploy_dir
140
+ try << MU.mommacat.deploy_dir+"/ansible/vaults/"+vault if MU.mommacat.deploy_dir
141
+ try.each { |maybe_dir|
142
+ if Dir.exist?(maybe_dir) and (item.nil? or File.exist?(maybe_dir+"/"+item))
143
+ dir = maybe_dir
144
+ break
145
+ end
146
+ }
147
+ if dir.nil?
126
148
  raise MuNoSuchSecret, "No such vault #{vault}"
127
149
  end
128
150
 
@@ -135,17 +157,23 @@ module MU
135
157
  cmd = %Q{#{ansibleExecDir}/ansible-vault view #{itempath} --vault-password-file #{pwfile}}
136
158
  MU.log cmd
137
159
  a = `#{cmd}`
138
- # If we happen to have stored recognizeable JSON, return it as parsed,
139
- # which is a behavior we're used to from Chef vault. Otherwise, return
140
- # a String.
160
+ # If we happen to have stored recognizeable JSON or YAML, return it
161
+ # as parsed, which is a behavior we're used to from Chef vault.
162
+ # Otherwise, return a String.
141
163
  begin
142
164
  data = JSON.parse(a)
143
- if field and data[field]
144
- data = data[field]
145
- end
146
165
  rescue JSON::ParserError
147
- data = a
166
+ begin
167
+ data = YAML.load(a)
168
+ rescue Psych::SyntaxError => e
169
+ data = a
170
+ end
148
171
  end
172
+ [vault, item, field].each { |tier|
173
+ if data and data.is_a?(Hash) and tier and data[tier]
174
+ data = data[tier]
175
+ end
176
+ }
149
177
  else
150
178
  data = []
151
179
  Dir.foreach(dir) { |entry|
@@ -160,7 +188,7 @@ module MU
160
188
 
161
189
  # see {MU::Groomer::Ansible.getSecret}
162
190
  def getSecret(vault: nil, item: nil, field: nil)
163
- self.class.getSecret(vault: vault, item: item, field: field)
191
+ self.class.getSecret(vault: vault, item: item, field: field, deploy_dir: @server.deploy.deploy_dir)
164
192
  end
165
193
 
166
194
  # Delete a Ansible data bag / Vault
@@ -215,7 +243,9 @@ module MU
215
243
  play = {
216
244
  "hosts" => @server.config['name']
217
245
  }
218
- play["become"] = "yes" if @server.config['ssh_user'] != "root"
246
+ if !@server.windows? and @server.config['ssh_user'] != "root"
247
+ play["become"] = "yes"
248
+ end
219
249
  play["roles"] = override_runlist if @server.config['run_list'] and !@server.config['run_list'].empty?
220
250
  play["vars"] = @server.config['ansible_vars'] if @server.config['ansible_vars']
221
251
 
@@ -227,7 +257,7 @@ module MU
227
257
  "#{@server.config['name']}.yml"
228
258
  end
229
259
 
230
- cmd = %Q{cd #{@ansible_path} && echo "#{purpose}" && #{@ansible_execs}/ansible-playbook -i hosts #{playbook} --limit=#{@server.mu_name} --vault-password-file #{pwfile} --timeout=30 --vault-password-file #{@ansible_path}/.vault_pw -u #{ssh_user}}
260
+ cmd = %Q{cd #{@ansible_path} && echo "#{purpose}" && #{@ansible_execs}/ansible-playbook -i hosts #{playbook} --limit=#{@server.windows? ? @server.canonicalIP : @server.mu_name} --vault-password-file #{pwfile} --timeout=30 --vault-password-file #{@ansible_path}/.vault_pw -u #{ssh_user}}
231
261
 
232
262
  retries = 0
233
263
  begin
@@ -252,6 +282,7 @@ module MU
252
282
  sleep 30
253
283
  retries += 1
254
284
  MU.log "Failed Ansible run, will retry (#{retries.to_s}/#{max_retries.to_s})", MU::NOTICE, details: cmd
285
+
255
286
  retry
256
287
  else
257
288
  tmpfile.unlink if tmpfile
@@ -275,12 +306,12 @@ module MU
275
306
  # Bootstrap our server with Ansible- basically, just make sure this node
276
307
  # is listed in our deployment's Ansible inventory.
277
308
  def bootstrap
278
- @inventory.add(@server.config['name'], @server.mu_name)
309
+ @inventory.add(@server.config['name'], @server.windows? ? @server.canonicalIP : @server.mu_name)
279
310
  play = {
280
311
  "hosts" => @server.config['name']
281
312
  }
282
313
 
283
- if @server.config['ssh_user'] != "root"
314
+ if !@server.windows? and @server.config['ssh_user'] != "root"
284
315
  play["become"] = "yes"
285
316
  end
286
317
 
@@ -292,9 +323,26 @@ module MU
292
323
  play["vars"] = @server.config['ansible_vars']
293
324
  end
294
325
 
326
+ if @server.windows?
327
+ play["vars"] ||= {}
328
+ play["vars"]["ansible_connection"] = "winrm"
329
+ play["vars"]["ansible_winrm_scheme"] = "https"
330
+ play["vars"]["ansible_winrm_transport"] = "ntlm"
331
+ play["vars"]["ansible_winrm_server_cert_validation"] = "ignore" # XXX this sucks; use Mu_CA.pem if we can get it to work
332
+ # play["vars"]["ansible_winrm_ca_trust_path"] = "#{MU.mySSLDir}/Mu_CA.pem"
333
+ play["vars"]["ansible_user"] = @server.config['windows_admin_username']
334
+ win_pw = @server.getWindowsAdminPassword
335
+
336
+ pwfile = MU::Groomer::Ansible.vaultPasswordFile
337
+ cmd = %Q{#{MU::Groomer::Ansible.ansibleExecDir}/ansible-vault}
338
+ output = %x{#{cmd} encrypt_string '#{win_pw.gsub(/'/, "\\\\'")}' --vault-password-file #{pwfile}}
339
+
340
+ play["vars"]["ansible_password"] = output
341
+ end
342
+
295
343
  File.open(@ansible_path+"/"+@server.config['name']+".yml", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f|
296
344
  f.flock(File::LOCK_EX)
297
- f.puts [play].to_yaml
345
+ f.puts [play].to_yaml.sub(/ansible_password: \|-?[\n\s]+/, 'ansible_password: ') # Ansible doesn't like this (legal) YAML
298
346
  f.flock(File::LOCK_UN)
299
347
  }
300
348
  end
@@ -351,11 +399,18 @@ module MU
351
399
  allvars['deployment']
352
400
  end
353
401
 
402
+ # Nuke everything associated with a deploy. Since we're just some files
403
+ # in the deploy directory, this doesn't have to do anything.
404
+ def self.cleanup(deploy_id, noop = false)
405
+ # deploy = MU::MommaCat.new(MU.deploy_id)
406
+ # inventory = Inventory.new(deploy)
407
+ end
408
+
354
409
  # Expunge Ansible resources associated with a node.
355
410
  # @param node [String]: The Mu name of the node in question.
356
411
  # @param _vaults_to_clean [Array<Hash>]: Dummy argument, part of this method's interface but not used by the Ansible layer
357
412
  # @param noop [Boolean]: Skip actual deletion, just state what we'd do
358
- def self.cleanup(node, _vaults_to_clean = [], noop = false)
413
+ def self.purge(node, _vaults_to_clean = [], noop = false)
359
414
  deploy = MU::MommaCat.new(MU.deploy_id)
360
415
  inventory = Inventory.new(deploy)
361
416
  # ansible_path = deploy.deploy_dir+"/ansible"
@@ -388,6 +443,51 @@ module MU
388
443
  if !system(cmd, "encrypt_string", string, "--name", name, "--vault-password-file", pwfile)
389
444
  raise MuError, "Failed Ansible command: #{cmd} encrypt_string <redacted> --name #{name} --vault-password-file"
390
445
  end
446
+ output
447
+ end
448
+
449
+ # Hunt down and return a path for a Python executable
450
+ # @return [String]
451
+ def self.pythonExecDir
452
+ path = nil
453
+
454
+ if File.exist?(BINDIR+"/python")
455
+ path = BINDIR
456
+ else
457
+ paths = [ansibleExecDir]
458
+ paths.concat(ENV['PATH'].split(/:/))
459
+ paths << "/usr/bin" # not always in path, esp in pared-down Docker images
460
+ paths.reject! { |p| p.nil? }
461
+ paths.uniq.each { |bindir|
462
+ if File.exist?(bindir+"/python")
463
+ path = bindir
464
+ break
465
+ end
466
+ }
467
+ end
468
+ path
469
+ end
470
+
471
+ # Make sure what's in our Python requirements.txt is reflected in the
472
+ # Python we're about to run for Ansible
473
+ def self.checkPythonDependencies(windows = false)
474
+ return nil if !ansibleExecDir
475
+
476
+ execline = File.readlines(ansibleExecDir+"/ansible-playbook").first.chomp.sub(/^#!/, '')
477
+ if !execline
478
+ MU.log "Unable to extract a Python executable from #{ansibleExecDir}/ansible-playbook", MU::ERR
479
+ return false
480
+ end
481
+
482
+ require 'tempfile'
483
+ f = Tempfile.new("pythoncheck")
484
+ f.puts "import ansible"
485
+ f.puts "import winrm" if windows
486
+ f.close
487
+
488
+ system(%Q{#{execline} #{f.path}})
489
+ f.unlink
490
+ $?.exitstatus == 0 ? true : false
391
491
  end
392
492
 
393
493
  # Hunt down and return a path for Ansible executables
@@ -397,7 +497,9 @@ module MU
397
497
  if File.exist?(BINDIR+"/ansible-playbook")
398
498
  path = BINDIR
399
499
  else
400
- ENV['PATH'].split(/:/).each { |bindir|
500
+ paths = ENV['PATH'].split(/:/)
501
+ paths << "/usr/bin"
502
+ paths.uniq.each { |bindir|
401
503
  if File.exist?(bindir+"/ansible-playbook")
402
504
  path = bindir
403
505
  if !File.exist?(bindir+"/ansible-vault")
@@ -35,6 +35,12 @@ module MU
35
35
  end
36
36
  }
37
37
 
38
+ # Are the Chef libraries present and accounted for?
39
+ def self.available?(windows = false)
40
+ loadChefLib
41
+ @chefloaded
42
+ end
43
+
38
44
  @chefloaded = false
39
45
  @chefload_semaphore = Mutex.new
40
46
  # Autoload is too brain-damaged to get Chef's subclasses/submodules, so
@@ -329,7 +335,7 @@ module MU
329
335
  }
330
336
  else
331
337
  MU.log "Invoking Chef over WinRM on #{@server.mu_name}: #{purpose}"
332
- winrm = @server.getWinRMSession(haveBootstrapped? ? 1 : max_retries)
338
+ winrm = @server.getWinRMSession(haveBootstrapped? ? 2 : max_retries)
333
339
  if @server.windows? and @server.windowsRebootPending?(winrm)
334
340
  # Windows frequently gets stuck here
335
341
  if retries > 5
@@ -362,7 +368,7 @@ module MU
362
368
  }
363
369
 
364
370
  if resp.exitcode == 1 and output_lines.join("\n").match(/Chef Client finished/)
365
- MU.log "resp.exit code 1"
371
+ MU.log output_lines.last
366
372
  elsif resp.exitcode != 0
367
373
  raise MU::Cloud::BootstrapTempFail if resp.exitcode == 35 or output_lines.join("\n").match(/REBOOT_SCHEDULED| WARN: Reboot requested:|Rebooting server at a recipe's request|Chef::Exceptions::Reboot/)
368
374
  raise MU::Groomer::RunError, output_lines.slice(output_lines.length-50, output_lines.length).join("")
@@ -415,9 +421,9 @@ module MU
415
421
  if retries < max_retries
416
422
  retries += 1
417
423
  MU.log "#{@server.mu_name}: Chef run '#{purpose}' failed after #{Time.new - runstart} seconds, retrying (#{retries}/#{max_retries})", MU::WARN, details: e.message.dup
418
- if purpose != "Base Windows configuration"
419
- windows_try_ssh = !windows_try_ssh
420
- end
424
+ # if purpose != "Base Windows configuration"
425
+ # windows_try_ssh = !windows_try_ssh
426
+ # end
421
427
  if e.is_a?(WinRM::WinRMError)
422
428
  if @server.windows? and retries >= 3 and retries % 3 == 0
423
429
  # Mix in a hard reboot if WinRM isn't answering
@@ -619,13 +625,20 @@ module MU
619
625
  kb.name_args = [@server.mu_name]
620
626
  kb.config[:manual] = true
621
627
  kb.config[:winrm_transport] = :ssl
622
- kb.config[:host] = @server.mu_name
623
628
  kb.config[:winrm_port] = 5986
624
629
  kb.config[:session_timeout] = timeout
625
630
  kb.config[:operation_timeout] = timeout
626
- kb.config[:winrm_authentication_protocol] = :cert
627
- kb.config[:winrm_client_cert] = "#{MU.mySSLDir}/#{@server.mu_name}-winrm.crt"
628
- kb.config[:winrm_client_key] = "#{MU.mySSLDir}/#{@server.mu_name}-winrm.key"
631
+ if retries % 2 == 0
632
+ kb.config[:host] = canonical_addr
633
+ kb.config[:winrm_authentication_protocol] = :basic
634
+ kb.config[:winrm_user] = @server.config['windows_admin_username']
635
+ kb.config[:winrm_password] = @server.getWindowsAdminPassword
636
+ else
637
+ kb.config[:host] = @server.mu_name
638
+ kb.config[:winrm_authentication_protocol] = :cert
639
+ kb.config[:winrm_client_cert] = "#{MU.mySSLDir}/#{@server.mu_name}-winrm.crt"
640
+ kb.config[:winrm_client_key] = "#{MU.mySSLDir}/#{@server.mu_name}-winrm.key"
641
+ end
629
642
  # kb.config[:ca_trust_file] = "#{MU.mySSLDir}/Mu_CA.pem"
630
643
  # XXX ca_trust_file doesn't work for some reason, so we have to set the below for now
631
644
  kb.config[:winrm_ssl_verify_mode] = :verify_none
@@ -675,7 +688,7 @@ module MU
675
688
  preClean(false) # it's ok for this to fail
676
689
  rescue StandardError => e
677
690
  end
678
- MU::Groomer::Chef.cleanup(@server.mu_name, nodeonly: true)
691
+ MU::Groomer::Chef.purge(@server.mu_name, nodeonly: true)
679
692
  @config['forced_preclean'] = true
680
693
  @server.reboot if @server.windows? # *sigh*
681
694
  end
@@ -792,12 +805,52 @@ retry
792
805
  end
793
806
  end
794
807
 
808
+ # Purge Chef resources matching a particular deploy
809
+ # @param deploy_id [String]
810
+ # @param noop [Boolean]
811
+ def self.cleanup(deploy_id, noop = false)
812
+ return nil if deploy_id.nil? or deploy_id.empty?
813
+ begin
814
+ if File.exist?(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb")
815
+ ::Chef::Config.from_file(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb")
816
+ end
817
+ deadnodes = []
818
+ ::Chef::Config[:environment] ||= MU.environment
819
+ q = ::Chef::Search::Query.new
820
+ begin
821
+ q.search("node", "tags_MU-ID:#{deploy_id}").each { |item|
822
+ next if item.is_a?(Integer)
823
+ item.each { |node|
824
+ deadnodes << node.name
825
+ }
826
+ }
827
+ rescue Net::HTTPServerException
828
+ end
829
+
830
+ begin
831
+ q.search("node", "name:#{deploy_id}-*").each { |item|
832
+ next if item.is_a?(Integer)
833
+ item.each { |node|
834
+ deadnodes << node.name
835
+ }
836
+ }
837
+ rescue Net::HTTPServerException
838
+ end
839
+ MU.log "Missed some Chef resources in node cleanup, purging now", MU::NOTICE if deadnodes.size > 0
840
+ deadnodes.uniq.each { |node|
841
+ MU::Groomer::Chef.purge(node, [], noop)
842
+ }
843
+ rescue LoadError
844
+ end
845
+
846
+ end
847
+
795
848
  # Expunge Chef resources associated with a node.
796
849
  # @param node [String]: The Mu name of the node in question.
797
850
  # @param vaults_to_clean [Array<Hash>]: Some vaults to expunge
798
851
  # @param noop [Boolean]: Skip actual deletion, just state what we'd do
799
852
  # @param nodeonly [Boolean]: Just delete the node and its keys, but leave other artifacts
800
- def self.cleanup(node, vaults_to_clean = [], noop = false, nodeonly: false)
853
+ def self.purge(node, vaults_to_clean = [], noop = false, nodeonly: false)
801
854
  loadChefLib
802
855
  MU.log "Deleting Chef resources associated with #{node}"
803
856
  if !nodeonly
@@ -33,6 +33,33 @@ module MU
33
33
  # Show DEBUG log entries and extra call stack and threading info
34
34
  LOUD = 2.freeze
35
35
 
36
+ # stash a hash map for color outputs
37
+ COLORMAP = {
38
+ MU::DEBUG => { :html => "orange", :ansi => :yellow },
39
+ MU::INFO => { :html => "green", :ansi => :green },
40
+ MU::NOTICE => { :html => "yellow", :ansi => :yellow },
41
+ MU::WARN => { :html => "orange", :ansi => :light_red },
42
+ MU::ERR => { :html => "red", :ansi => :red }
43
+ }.freeze
44
+
45
+ # minimum log verbosity at which we'll print various types of messages
46
+ PRINT_MSG_IF = {
47
+ MU::DEBUG => { :msg => LOUD, :details => LOUD },
48
+ MU::INFO => { :msg => NORMAL, :details => LOUD },
49
+ MU::NOTICE => { :msg => nil, :details => QUIET },
50
+ MU::WARN => { :msg => nil, :details => SILENT },
51
+ MU::ERR => { :msg => nil, :details => nil }
52
+ }.freeze
53
+
54
+ # Syslog equivalents of our log levels
55
+ SYSLOG_MAP = {
56
+ MU::DEBUG => Syslog::LOG_DEBUG,
57
+ MU::INFO => Syslog::LOG_NOTICE,
58
+ MU::NOTICE => Syslog::LOG_NOTICE,
59
+ MU::WARN => Syslog::LOG_WARNING,
60
+ MU::ERR => Syslog::LOG_ERR
61
+ }.freeze
62
+
36
63
  attr_accessor :verbosity
37
64
  @verbosity = MU::Logger::NORMAL
38
65
  @quiet = false
@@ -76,59 +103,28 @@ module MU
76
103
  html ||= @html
77
104
  handle ||= @handle
78
105
  color ||= @color
79
- return if verbosity == MU::Logger::SILENT
80
- return if verbosity < MU::Logger::LOUD and level == DEBUG
81
- return if verbosity < MU::Logger::NORMAL and level == INFO
82
106
 
83
- # By which we mean, "get the filename (with the .rb stripped off) which
84
- # originated the call to this method. Which, for our purposes, is the
85
- # MU subclass that called us. Useful information. And it looks like Perl.
86
- mod_root = Regexp.quote("#{ENV['MU_LIBDIR']}/modules/mu/")
87
- bin_root = Regexp.quote("#{ENV['MU_INSTALLDIR']}/bin/")
88
- caller_name = caller[1]
107
+ if verbosity == MU::Logger::SILENT or (verbosity < MU::Logger::LOUD and level == DEBUG) or (verbosity < MU::Logger::NORMAL and level == INFO)
108
+ return
109
+ end
89
110
 
90
- caller_name.sub!(/:.*/, "")
91
- caller_name.sub!(/^\.\//, "")
92
- caller_name.sub!(/^#{mod_root}/, "")
93
- caller_name.sub!(/^#{bin_root}/, "")
94
- caller_name.sub!(/\.r[ub]$/, "")
95
- caller_name.sub!(/#{Regexp.quote(MU.myRoot)}\//, "")
96
- caller_name.sub!(/^modules\//, "")
111
+ if level == SUMMARY
112
+ @summary << msg
113
+ return
114
+ end
115
+
116
+ caller_name = extract_caller_name(caller[1])
97
117
 
98
118
  time = Time.now.strftime("%b %d %H:%M:%S").to_s
99
119
 
100
120
  Syslog.open("Mu/"+caller_name, Syslog::LOG_PID, Syslog::LOG_DAEMON | Syslog::LOG_LOCAL3) if !Syslog.opened?
101
- if !details.nil?
102
- if details.is_a?(Hash) and details.has_key?(:details)
103
- details = details[:details]
104
- end
105
- details = PP.pp(details, '') if !details.is_a?(String)
106
- end
107
- details = "<pre>"+details+"</pre>" if html
108
- # We get passed literal quoted newlines sometimes, fix 'em. Get Windows'
109
- # ugly line feeds too.
110
- if !details.nil?
111
- details = details.dup # in case it's frozen or something
112
- details.gsub!(/\\n/, "\n")
113
- details.gsub!(/(\\r|\r)/, "")
114
- end
121
+
122
+ details = format_details(details, html)
115
123
 
116
124
  msg = msg.first if msg.is_a?(Array)
117
125
  msg = "" if msg == nil
118
126
  msg = msg.to_s if !msg.is_a?(String) and msg.respond_to?(:to_s)
119
127
 
120
- # wrapper for writing a log entry to multiple filehandles
121
- # @param handles [Array<IO>]
122
- # @param msgs [Array<String>]
123
- def write(handles = [], msgs = [])
124
- return if handles.nil? or msgs.nil?
125
- handles.each { |h|
126
- msgs.each { |m|
127
- h.puts m
128
- }
129
- }
130
- end
131
-
132
128
  @@log_semaphere.synchronize {
133
129
  handles = [handle]
134
130
  extra_logfile = if deploy and deploy.deploy_dir and Dir.exist?(deploy.deploy_dir)
@@ -137,110 +133,41 @@ module MU
137
133
  handles << extra_logfile if extra_logfile
138
134
  msgs = []
139
135
 
140
- case level
141
- when SUMMARY
142
- @summary << msg
143
- when DEBUG
144
- if verbosity >= MU::Logger::LOUD
145
- if html
146
- html_out "#{time} - #{caller_name} - #{msg}", "orange"
147
- html_out "&nbsp;#{details}" if details
148
- elsif color
149
- msgs << "#{time} - #{caller_name} - #{msg}".yellow.on_black
150
- msgs << "#{details}".white.on_black if details
151
- else
152
- msgs << "#{time} - #{caller_name} - #{msg}"
153
- msgs << "#{details}" if details
154
- end
155
- Syslog.log(Syslog::LOG_DEBUG, msg.gsub(/%/, ''))
156
- Syslog.log(Syslog::LOG_DEBUG, details.gsub(/%/, '')) if details
157
- end
158
- when INFO
159
- if verbosity >= MU::Logger::NORMAL
160
- if html
161
- html_out "#{time} - #{caller_name} - #{msg}", "green"
162
- elsif color
163
- msgs << "#{time} - #{caller_name} - #{msg}".green.on_black
164
- else
165
- msgs << "#{time} - #{caller_name} - #{msg}"
166
- end
167
- if verbosity >= MU::Logger::LOUD
168
- if html
169
- html_out "&nbsp;#{details}"
170
- elsif color
171
- msgs << "#{details}".white.on_black if details
172
- else
173
- msgs << "#{details}" if details
174
- end
175
- end
176
- Syslog.log(Syslog::LOG_NOTICE, msg.gsub(/%/, ''))
177
- Syslog.log(Syslog::LOG_NOTICE, details.gsub(/%/, '')) if details
178
- end
179
- when NOTICE
180
- if html
181
- html_out "#{time} - #{caller_name} - #{msg}", "yellow"
182
- elsif color
183
- msgs << "#{time} - #{caller_name} - #{msg}".yellow.on_black
184
- else
185
- msgs << "#{time} - #{caller_name} - #{msg}"
186
- end
187
- if verbosity >= MU::Logger::QUIET
188
- if html
189
- html_out "#{caller_name} - #{msg}"
190
- elsif color
191
- msgs << "#{details}".white.on_black if details
192
- else
193
- msgs << "#{details}" if details
194
- end
195
- end
196
- Syslog.log(Syslog::LOG_NOTICE, msg.gsub(/%/, ''))
197
- Syslog.log(Syslog::LOG_NOTICE, details.gsub(/%/, '')) if details
198
- when WARN
199
- if html
200
- html_out "#{time} - #{caller_name} - #{msg}", "orange"
201
- elsif color
202
- msgs << "#{time} - #{caller_name} - #{msg}".light_red.on_black
203
- else
204
- msgs << "#{time} - #{caller_name} - #{msg}"
205
- end
206
- if verbosity >= MU::Logger::SILENT
207
- if html
208
- html_out "#{caller_name} - #{msg}"
209
- elsif color
210
- msgs << "#{details}".white.on_black if details
211
- else
212
- msgs << "#{details}" if details
213
- end
214
- end
215
- Syslog.log(Syslog::LOG_WARNING, msg.gsub(/%/, ''))
216
- Syslog.log(Syslog::LOG_WARNING, details.gsub(/%/, '')) if details
217
- when ERR
218
- if html
219
- html_out "#{time} - #{caller_name} - #{msg}", "red"
220
- html_out "&nbsp;#{details}" if details
221
- elsif color
222
- msgs << "#{time} - #{caller_name} - #{msg}".red.on_black
223
- msgs << "#{details}".white.on_black if details
224
- else
225
- msgs << "#{time} - #{caller_name} - #{msg}"
226
- msgs << "#{details}" if details
227
- end
228
- Syslog.log(Syslog::LOG_ERR, msg.gsub(/%/, ''))
229
- Syslog.log(Syslog::LOG_ERR, details.gsub(/%/, '')) if details
136
+ if !PRINT_MSG_IF[level][:msg] or level >= PRINT_MSG_IF[level][:msg]
137
+ if html
138
+ html_out "#{time} - #{caller_name} - #{msg}", COLORMAP[level][:html]
139
+ else
140
+ str = "#{time} - #{caller_name} - #{msg}"
141
+ str = str.send(COLORMAP[level][:ansi]).on_black if color
142
+ msgs << str
143
+ end
144
+ Syslog.log(SYSLOG_MAP[level], msg.gsub(/%/, ''))
145
+ end
146
+
147
+ if details and (!PRINT_MSG_IF[level][:details] or level >= PRINT_MSG_IF[level][:details])
148
+ if html
149
+ html_out "&nbsp;#{details}"
230
150
  else
231
- if html
232
- html_out "#{time} - #{caller_name} - #{msg}"
233
- html_out "&nbsp;#{details}" if details
234
- elsif color
235
- msgs << "#{time} - #{caller_name} - #{msg}".white.on_black
236
- msgs << "#{details}".white.on_black if details
237
- else
238
- msgs << "#{time} - #{caller_name} - #{msg}"
239
- msgs << "#{details}" if details
240
- end
241
- Syslog.log(Syslog::LOG_NOTICE, msg.gsub(/%/, ''))
242
- Syslog.log(Syslog::LOG_NOTICE, details.gsub(/%/, '')) if details
151
+ details = details.white.on_black if color
152
+ msgs << details
153
+ end
154
+ Syslog.log(SYSLOG_MAP[level], details.gsub(/%/, ''))
243
155
  end
156
+
157
+ # else
158
+ # if html
159
+ # html_out "#{time} - #{caller_name} - #{msg}"
160
+ # html_out "&nbsp;#{details}" if details
161
+ # elsif color
162
+ # msgs << "#{time} - #{caller_name} - #{msg}".white.on_black
163
+ # msgs << "#{details}".white.on_black if details
164
+ # else
165
+ # msgs << "#{time} - #{caller_name} - #{msg}"
166
+ # msgs << "#{details}" if details
167
+ # end
168
+ # Syslog.log(Syslog::LOG_NOTICE, msg.gsub(/%/, ''))
169
+ # Syslog.log(Syslog::LOG_NOTICE, details.gsub(/%/, '')) if details
170
+
244
171
  write(handles, msgs)
245
172
 
246
173
  extra_logfile.close if extra_logfile
@@ -250,6 +177,43 @@ module MU
250
177
 
251
178
  private
252
179
 
180
+ def format_details(details, html = false)
181
+ return if details.nil?
182
+
183
+ if details.is_a?(Hash) and details.has_key?(:details)
184
+ details = details[:details]
185
+ end
186
+ details = PP.pp(details, '') if !details.is_a?(String)
187
+
188
+ details = "<pre>"+details+"</pre>" if html
189
+ # We get passed literal quoted newlines sometimes, fix 'em. Get Windows'
190
+ # ugly line feeds too.
191
+
192
+ details = details.dup # in case it's frozen or something
193
+ details.gsub!(/\\n/, "\n")
194
+ details.gsub!(/(\\r|\r)/, "")
195
+
196
+ details
197
+ end
198
+
199
+ # By which we mean, "get the filename (with the .rb stripped off) which
200
+ # originated the call to this method. Which, for our purposes, is the
201
+ # MU subclass that called us. Useful information. And it looks like Perl.
202
+ def extract_caller_name(caller_name)
203
+ return nil if !caller_name or !caller_name.is_a?(String)
204
+ mod_root = Regexp.quote("#{ENV['MU_LIBDIR']}/modules/mu/")
205
+ bin_root = Regexp.quote("#{ENV['MU_INSTALLDIR']}/bin/")
206
+
207
+ caller_name.sub!(/:.*/, "")
208
+ caller_name.sub!(/^\.\//, "")
209
+ caller_name.sub!(/^#{mod_root}/, "")
210
+ caller_name.sub!(/^#{bin_root}/, "")
211
+ caller_name.sub!(/\.r[ub]$/, "")
212
+ caller_name.sub!(/#{Regexp.quote(MU.myRoot)}\//, "")
213
+ caller_name.sub!(/^modules\//, "")
214
+ caller_name
215
+ end
216
+
253
217
  # Output a log message as HTML.
254
218
  #
255
219
  # @param msg [String]: The log message to print
@@ -259,5 +223,17 @@ module MU
259
223
  @handle.puts "<span style='color:#{rgb.css_rgb};'>#{msg}</span>"
260
224
  end
261
225
 
226
+ # wrapper for writing a log entry to multiple filehandles
227
+ # @param handles [Array<IO>]
228
+ # @param msgs [Array<String>]
229
+ def write(handles = [], msgs = [])
230
+ return if handles.nil? or msgs.nil?
231
+ handles.each { |h|
232
+ msgs.each { |m|
233
+ h.puts m
234
+ }
235
+ }
236
+ end
237
+
262
238
  end #class
263
239
  end #module