etch 3.12

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.
data/lib/etch.rb ADDED
@@ -0,0 +1,1391 @@
1
+ require 'find' # Find.find
2
+ require 'pathname' # absolute?
3
+ require 'digest/sha1' # hexdigest
4
+ require 'base64' # decode64, encode64
5
+ require 'fileutils' # mkdir_p
6
+ # By default we try to use libxml, falling back to rexml if it is not
7
+ # available. The xmllib environment variable can be used to force one library
8
+ # or the other, mostly for testing purposes.
9
+ begin
10
+ if !ENV['xmllib'] || ENV['xmllib'] == 'libxml'
11
+ require 'rubygems' # libxml is a gem
12
+ require 'libxml'
13
+ @@xmllib = :libxml
14
+ else
15
+ raise LoadError
16
+ end
17
+ rescue LoadError
18
+ if !ENV['xmllib'] || ENV['xmllib'] == 'rexml'
19
+ require 'rexml/document'
20
+ @@xmllib = :rexml
21
+ else
22
+ raise
23
+ end
24
+ end
25
+ require 'erb'
26
+ require 'versiontype' # Version
27
+ require 'logger'
28
+
29
+ class Etch
30
+
31
+ # FIXME: I'm not really proud of this, it seems like there ought to be a way
32
+ # to just use one logger. The problem is that on the server we'd like to
33
+ # use RAILS_DEFAULT_LOGGER for general logging (which is logging to
34
+ # log/production.log), but be able to turn on debug-level logging for
35
+ # individual connections (via the debug parameter sent in the HTTP
36
+ # requests). If we twiddle the log level of RAILS_DEFAULT_LOGGER then all
37
+ # connections coming in at the same time as the debug connection will also
38
+ # get logged as debug, making the logs confusing. And if the debug
39
+ # connection aborts for some reason we also risk leaving
40
+ # RAILS_DEFAULT_LOGGER set to debug, flooding the logs. So it seems like we
41
+ # need a seperate logger for debugging. But that just seems wrong somehow.
42
+ # We don't want to just dup RAILS_DEFAULT_LOGGER for each connection, even
43
+ # if Logger didn't immediately blow up we'd probably end up with scrambled
44
+ # logs as simultaneous connections tried to write at the same time. Or
45
+ # maybe that would work, depending on how Ruby and the OS buffer writes to
46
+ # the file?
47
+ def initialize(logger, debug_logger)
48
+ @logger = logger
49
+ @dlogger = debug_logger
50
+ end
51
+
52
+ # configdir: Directory containing etch configuration
53
+ # facts: facts in the form of Facter.to_hash
54
+ # request: hash with keys of :files and/or :commands
55
+ # :files => {'/etc/motd' => {:orig => '/path/to/orig', :local_requests => requestdata}}
56
+ # :commands => {'packages' => {}, 'solaris_ldapclient' => {}}
57
+ # If the request hash is empty all items are generated and returned.
58
+ def generate(configdir, facts, request={})
59
+ @configdir = configdir
60
+ @facts = facts
61
+ @request = request
62
+ @fqdn = @facts['fqdn']
63
+
64
+ if !@fqdn
65
+ raise "fqdn fact not supplied"
66
+ end
67
+
68
+ if !File.directory?(@configdir)
69
+ raise "Config directory #{@configdir} doesn't exist"
70
+ end
71
+
72
+ # Set up all the variables that point to various directories within our
73
+ # base directory.
74
+ @sourcebase = "#{@configdir}/source"
75
+ @commandsbase = "#{@configdir}/commands"
76
+ @sitelibbase = "#{@configdir}/sitelibs"
77
+ @config_dtd_file = "#{@configdir}/config.dtd"
78
+ @commands_dtd_file = "#{@configdir}/commands.dtd"
79
+ @defaults_file = "#{@configdir}/defaults.xml"
80
+ @nodes_file = "#{@configdir}/nodes.xml"
81
+ @nodegroups_file = "#{@configdir}/nodegroups.xml"
82
+
83
+ #
84
+ # Load the DTD which is used to validate config.xml files
85
+ #
86
+
87
+ @config_dtd = Etch.xmlloaddtd(@config_dtd_file)
88
+ @commands_dtd = Etch.xmlloaddtd(@commands_dtd_file)
89
+
90
+ #
91
+ # Load the defaults.xml file which sets defaults for parameters that the
92
+ # users don't specify in their config.xml files.
93
+ #
94
+
95
+ @defaults_xml = Etch.xmlload(@defaults_file)
96
+
97
+ #
98
+ # Load the nodes file
99
+ #
100
+
101
+ @nodes_xml = Etch.xmlload(@nodes_file)
102
+ # Extract the groups for this node
103
+ thisnodeelem = Etch.xmlfindfirst(@nodes_xml, "/nodes/node[@name='#{@fqdn}']")
104
+ groupshash = {}
105
+ if thisnodeelem
106
+ Etch.xmleach(thisnodeelem, 'group') { |group| groupshash[Etch.xmltext(group)] = true }
107
+ else
108
+ @logger.warn "No entry found for node #{@fqdn} in nodes.xml"
109
+ # Some folks might want to terminate here
110
+ #raise "No entry found for node #{@fqdn} in nodes.xml"
111
+ end
112
+ @dlogger.debug "Native groups for node #{@fqdn}: #{groupshash.keys.sort.join(',')}"
113
+
114
+ #
115
+ # Load the node groups file
116
+ #
117
+
118
+ @nodegroups_xml = Etch.xmlload(@nodegroups_file)
119
+
120
+ # Extract the node group hierarchy into a hash for easy reference
121
+ @group_hierarchy = {}
122
+ Etch.xmleach(@nodegroups_xml, '/nodegroups/nodegroup') do |parent|
123
+ Etch.xmleach(parent, 'child') do |child|
124
+ @group_hierarchy[Etch.xmltext(child)] = [] if !@group_hierarchy[Etch.xmltext(child)]
125
+ @group_hierarchy[Etch.xmltext(child)] << parent.attributes['name']
126
+ end
127
+ end
128
+
129
+ # Fill out the list of groups for this node with any parent groups
130
+ parentshash = {}
131
+ groupshash.keys.each do |group|
132
+ parents = get_parent_nodegroups(group)
133
+ parents.each { |parent| parentshash[parent] = true }
134
+ end
135
+ parentshash.keys.each { |parent| groupshash[parent] = true }
136
+ @dlogger.debug "Added groups for node #{@fqdn} due to node group hierarchy: #{parentshash.keys.sort.join(',')}"
137
+
138
+ # Run the external node grouper
139
+ externalhash = {}
140
+ IO.popen(File.join(@configdir, 'nodegrouper') + ' ' + @fqdn) do |pipe|
141
+ pipe.each { |group| externalhash[group.chomp] = true }
142
+ end
143
+ if !$?.success?
144
+ raise "External node grouper exited with error #{$?.exitstatus}"
145
+ end
146
+ externalhash.keys.each { |external| groupshash[external] = true }
147
+ @dlogger.debug "Added groups for node #{@fqdn} due to external node grouper: #{externalhash.keys.sort.join(',')}"
148
+
149
+ @groups = groupshash.keys.sort
150
+ @dlogger.debug "Total groups for node #{@fqdn}: #{@groups.join(',')}"
151
+
152
+ #
153
+ # Build up a list of files to generate, either from the request or from
154
+ # the source repository if the request is for all files
155
+ #
156
+
157
+ filelist = []
158
+ if request.empty?
159
+ @dlogger.debug "Building complete file list for request from #{@fqdn}"
160
+ Find.find(@sourcebase) do |path|
161
+ if File.directory?(path) && File.exist?(File.join(path, 'config.xml'))
162
+ # Strip @sourcebase from start of path
163
+ filelist << path.sub(Regexp.new('^' + Regexp.escape(@sourcebase)), '')
164
+ end
165
+ end
166
+ elsif request[:files]
167
+ @dlogger.debug "Building file list based on request for specific files from #{@fqdn}"
168
+ filelist = request[:files].keys
169
+ end
170
+ @dlogger.debug "Generating #{filelist.length} files"
171
+
172
+ #
173
+ # Loop over each file in the request and generate it
174
+ #
175
+
176
+ @filestack = {}
177
+ @already_generated = {}
178
+ @generation_status = {}
179
+ @configs = {}
180
+ @need_orig = {}
181
+ @allcommands = {}
182
+ @retrycommands = {}
183
+
184
+ filelist.each do |file|
185
+ @dlogger.debug "Generating #{file}"
186
+ generate_file(file, request)
187
+ end
188
+
189
+ #
190
+ # Generate configuration commands
191
+ #
192
+
193
+ commandnames = []
194
+ if request.empty?
195
+ @dlogger.debug "Building complete configuration commands for request from #{@fqdn}"
196
+ Find.find(@commandsbase) do |path|
197
+ if File.directory?(path) && File.exist?(File.join(path, 'commands.xml'))
198
+ commandnames << File.basename(path)
199
+ end
200
+ end
201
+ elsif request[:commands]
202
+ @dlogger.debug "Building commands based on request for specific commands from #{@fqdn}"
203
+ commandnames = request[:commands].keys
204
+ end
205
+ @dlogger.debug "Generating #{commandnames.length} commands"
206
+ commandnames.each do |commandname|
207
+ @dlogger.debug "Generating command #{commandname}"
208
+ generate_commands(commandname, request)
209
+ end
210
+
211
+ #
212
+ # Returned our assembled results
213
+ #
214
+
215
+ {:configs => @configs,
216
+ :need_orig => @need_orig,
217
+ :allcommands => @allcommands,
218
+ :retrycommands => @retrycommands}
219
+ end
220
+
221
+ #
222
+ # Private subroutines
223
+ #
224
+ private
225
+
226
+ # Recursive method to get all of the parents of a node group
227
+ def get_parent_nodegroups(group)
228
+ parentshash = {}
229
+ if @group_hierarchy[group]
230
+ @group_hierarchy[group].each do |parent|
231
+ parentshash[parent] = true
232
+ grandparents = get_parent_nodegroups(parent)
233
+ grandparents.each { |gp| parentshash[gp] = true }
234
+ end
235
+ end
236
+ parentshash.keys.sort
237
+ end
238
+
239
+ # Returns the value of the generation_status variable, see comments in
240
+ # method for possible values.
241
+ def generate_file(file, request)
242
+ # Skip files we've already generated in response to <depend>
243
+ # statements.
244
+ if @already_generated[file]
245
+ @dlogger.debug "Skipping already generated #{file}"
246
+ # Return the status of that previous generation
247
+ return @generation_status[file]
248
+ end
249
+
250
+ # Check for circular dependencies, otherwise we're vulnerable
251
+ # to going into an infinite loop
252
+ if @filestack[file]
253
+ raise "Circular dependency detected for #{file}"
254
+ end
255
+ @filestack[file] = true
256
+
257
+ config_xml_file = File.join(@sourcebase, file, 'config.xml')
258
+ if !File.exist?(config_xml_file)
259
+ raise "config.xml for #{file} does not exist"
260
+ end
261
+
262
+ # Load the config.xml file
263
+ config_xml = Etch.xmlload(config_xml_file)
264
+
265
+ # Filter the config.xml file by looking for attributes
266
+ begin
267
+ configfilter!(Etch.xmlroot(config_xml))
268
+ rescue Exception => e
269
+ raise e.exception("Error filtering config.xml for #{file}:\n" + e.message)
270
+ end
271
+
272
+ # Validate the filtered file against config.dtd
273
+ if !Etch.xmlvalidate(config_xml, @config_dtd)
274
+ raise "Filtered config.xml for #{file} fails validation"
275
+ end
276
+
277
+ generation_status = :unknown
278
+ # As we go through the process of generating the file we'll end up with
279
+ # four possible outcomes:
280
+ # fatal error: raise an exception
281
+ # failure: we're missing needed data for this file or a dependency,
282
+ # generally the original file
283
+ # success: we successfully processed a valid configuration
284
+ # unknown: no valid configuration nor errors encountered, probably because
285
+ # filtering removed everything from the config.xml file. This
286
+ # should be considered a successful outcome, it indicates the
287
+ # caller/client provided us with all required data and our result
288
+ # is that no action needs to be taken.
289
+ # We keep track of which of the failure, success or unknown states we end
290
+ # up in via the generation_status variable. We initialize it to :unknown.
291
+ # If we encounter either failure or success we set it to false or :success.
292
+ catch :generate_done do
293
+ # Generate any other files that this file depends on
294
+ depends = []
295
+ proceed = true
296
+ Etch.xmleach(config_xml, '/config/depend') do |depend|
297
+ @dlogger.debug "Generating dependency #{Etch.xmltext(depend)}"
298
+ depends << Etch.xmltext(depend)
299
+ r = generate_file(Etch.xmltext(depend), request)
300
+ proceed = proceed && r
301
+ end
302
+ # Also generate any commands that this file depends on
303
+ Etch.xmleach(config_xml, '/config/dependcommand') do |dependcommand|
304
+ @dlogger.debug "Generating command dependency #{Etch.xmltext(dependcommand)}"
305
+ r = generate_commands(Etch.xmltext(dependcommand), request)
306
+ proceed = proceed && r
307
+ end
308
+
309
+ if !proceed
310
+ @dlogger.debug "One or more dependencies of #{file} need data from client"
311
+ end
312
+
313
+ # Make sure we have the original contents for this file
314
+ original_file = nil
315
+ if request[:files] && request[:files][file] && request[:files][file][:orig]
316
+ original_file = request[:files][file][:orig]
317
+ else
318
+ @dlogger.debug "Need original contents of #{file} from client"
319
+ proceed = false
320
+ end
321
+
322
+ if !proceed
323
+ # If any file dependency failed to generate (due to a need for orig
324
+ # contents from the client) then we need to tell the client to request
325
+ # all of the files in the dependency tree again.
326
+ #
327
+ # For example, we have afile which depends on bfile and cfile. The
328
+ # user requests afile and bfile on the command line. The client sends
329
+ # sums for afile and bfile. The server sees the need for cfile's sum, so
330
+ # it sends back contents for bfile and a sum request for cfile and afile
331
+ # (since afile depends on bfile). The client sends sums for afile and
332
+ # cfile. The server sends back contents for cfile, and a sum request for
333
+ # bfile and afile. This repeats forever as the server isn't smart enough
334
+ # to ask for everything it needs and the client isn't smart enough to send
335
+ # everything.
336
+ depends.each { |depend| @need_orig[depend] = true }
337
+
338
+ # Tell the client to request this file again
339
+ @need_orig[file] = true
340
+
341
+ # Strip this file's config down to the bare necessities
342
+ filter_xml_completely!(config_xml, ['depend', 'setup'])
343
+
344
+ # And hit the eject button
345
+ generation_status = false
346
+ throw :generate_done
347
+ end
348
+
349
+ # Change into the corresponding directory so that the user can
350
+ # refer to source files and scripts by their relative pathnames.
351
+ Dir::chdir "#{@sourcebase}/#{file}"
352
+
353
+ # See what type of action the user has requested
354
+
355
+ # Check to see if the user has requested that we revert back to the
356
+ # original file.
357
+ if Etch.xmlfindfirst(config_xml, '/config/revert')
358
+ # Pass the revert action back to the client
359
+ filter_xml!(config_xml, ['revert'])
360
+ generation_status = :success
361
+ throw :generate_done
362
+ end
363
+
364
+ # Perform any server setup commands
365
+ if Etch.xmlfindfirst(config_xml, '/config/server_setup')
366
+ @dlogger.debug "Processing server setup commands"
367
+ Etch.xmleach(config_xml, '/config/server_setup/exec') do |cmd|
368
+ @dlogger.debug " Executing #{Etch.xmltext(cmd)}"
369
+ # Explicitly invoke using /bin/sh so that syntax like
370
+ # "FOO=bar myprogram" works.
371
+ success = system('/bin/sh', '-c', Etch.xmltext(cmd))
372
+ if !success
373
+ raise "Server setup command #{Etch.xmltext(cmd)} for file #{file} exited with non-zero value"
374
+ end
375
+ end
376
+ end
377
+
378
+ # Pull out any local requests
379
+ local_requests = nil
380
+ if request[:files] && request[:files][file] && request[:files][file][:local_requests]
381
+ local_requests = request[:files][file][:local_requests]
382
+ end
383
+
384
+ #
385
+ # Regular file
386
+ #
387
+
388
+ if Etch.xmlfindfirst(config_xml, '/config/file')
389
+ #
390
+ # Assemble the contents for the file
391
+ #
392
+ newcontents = ''
393
+
394
+ if Etch.xmlfindfirst(config_xml, '/config/file/source/plain')
395
+ plain_elements = Etch.xmlarray(config_xml, '/config/file/source/plain')
396
+ if check_for_inconsistency(plain_elements)
397
+ raise "Inconsistent 'plain' entries for #{file}"
398
+ end
399
+
400
+ # Just slurp the file in
401
+ plain_file = Etch.xmltext(plain_elements.first)
402
+ newcontents = IO::read(plain_file)
403
+ elsif Etch.xmlfindfirst(config_xml, '/config/file/source/template')
404
+ template_elements = Etch.xmlarray(config_xml, '/config/file/source/template')
405
+ if check_for_inconsistency(template_elements)
406
+ raise "Inconsistent 'template' entries for #{file}"
407
+ end
408
+
409
+ # Run the template through ERB to generate the file contents
410
+ template = Etch.xmltext(template_elements.first)
411
+ external = EtchExternalSource.new(file, original_file, @facts, @groups, local_requests, @sourcebase, @commandsbase, @sitelibbase, @dlogger)
412
+ newcontents = external.process_template(template)
413
+ elsif Etch.xmlfindfirst(config_xml, '/config/file/source/script')
414
+ script_elements = Etch.xmlarray(config_xml, '/config/file/source/script')
415
+ if check_for_inconsistency(script_elements)
416
+ raise "Inconsistent 'script' entries for #{file}"
417
+ end
418
+
419
+ # Run the script to generate the file contents
420
+ script = Etch.xmltext(script_elements.first)
421
+ external = EtchExternalSource.new(file, original_file, @facts, @groups, local_requests, @sourcebase, @commandsbase, @sitelibbase, @dlogger)
422
+ newcontents = external.run_script(script)
423
+ elsif Etch.xmlfindfirst(config_xml, '/config/file/always_manage_metadata')
424
+ # always_manage_metadata is a special case where we proceed
425
+ # even if we don't have any source for file contents.
426
+ else
427
+ # If the filtering has removed the source for this file's
428
+ # contents, that means it doesn't apply to this node.
429
+ @dlogger.debug "No configuration for file #{file} contents, doing nothing"
430
+
431
+ # This check is unnecessary for the proper functioning of
432
+ # the application, as the next check (for empty contents) is
433
+ # in some senses a superset. However, the slightly
434
+ # different debug messages (no source for contents, versus
435
+ # empty contents) might help the user.
436
+ end
437
+
438
+ # If the new contents are empty, and the user hasn't asked us to
439
+ # keep empty files or always manage the metadata, then assume
440
+ # this file is not applicable to this node and do nothing.
441
+ if newcontents == '' &&
442
+ ! Etch.xmlfindfirst(config_xml, '/config/file/allow_empty') &&
443
+ ! Etch.xmlfindfirst(config_xml, '/config/file/always_manage_metadata')
444
+ @dlogger.debug "New contents for file #{file} empty, doing nothing"
445
+ else
446
+ # Finish assembling the file contents as long as we're not
447
+ # proceeding based only on always_manage_metadata. If we are
448
+ # proceeding based only on always_manage_metadata we want to make
449
+ # sure that the only action we'll take is to manage metadata, not
450
+ # muck with the file's contents.
451
+ if !(newcontents == '' &&
452
+ Etch.xmlfindfirst(config_xml, '/config/file/always_manage_metadata'))
453
+ # Add the warning message (if defined)
454
+ warning_file = nil
455
+ if Etch.xmlfindfirst(config_xml, '/config/file/warning_file')
456
+ if !Etch.xmltext(Etch.xmlfindfirst(config_xml, '/config/file/warning_file')).empty?
457
+ warning_file = Etch.xmltext(Etch.xmlfindfirst(config_xml, '/config/file/warning_file'))
458
+ end
459
+ elsif Etch.xmlfindfirst(@defaults_xml, '/config/file/warning_file')
460
+ if !Etch.xmltext(Etch.xmlfindfirst(@defaults_xml, '/config/file/warning_file')).empty?
461
+ warning_file = Etch.xmltext(Etch.xmlfindfirst(@defaults_xml, '/config/file/warning_file'))
462
+ end
463
+ end
464
+ if warning_file
465
+ warning = ''
466
+
467
+ # First the comment opener
468
+ comment_open = nil
469
+ if Etch.xmlfindfirst(config_xml, '/config/file/comment_open')
470
+ comment_open = Etch.xmltext(Etch.xmlfindfirst(config_xml, '/config/file/comment_open'))
471
+ elsif Etch.xmlfindfirst(@defaults_xml, '/config/file/comment_open')
472
+ comment_open = Etch.xmltext(Etch.xmlfindfirst(@defaults_xml, '/config/file/comment_open'))
473
+ end
474
+ if comment_open && !comment_open.empty?
475
+ warning << comment_open << "\n"
476
+ end
477
+
478
+ # Then the message
479
+ comment_line = '# '
480
+ if Etch.xmlfindfirst(config_xml, '/config/file/comment_line')
481
+ comment_line = Etch.xmltext(Etch.xmlfindfirst(config_xml, '/config/file/comment_line'))
482
+ elsif Etch.xmlfindfirst(@defaults_xml, '/config/file/comment_line')
483
+ comment_line = Etch.xmltext(Etch.xmlfindfirst(@defaults_xml, '/config/file/comment_line'))
484
+ end
485
+
486
+ warnpath = Pathname.new(warning_file)
487
+ if !File.exist?(warning_file) && !warnpath.absolute?
488
+ warning_file = File.expand_path(warning_file, @configdir)
489
+ end
490
+
491
+ File.open(warning_file) do |warnfile|
492
+ while line = warnfile.gets
493
+ warning << comment_line << line
494
+ end
495
+ end
496
+
497
+ # And last the comment closer
498
+ comment_close = nil
499
+ if Etch.xmlfindfirst(config_xml, '/config/file/comment_close')
500
+ comment_close = Etch.xmltext(Etch.xmlfindfirst(config_xml, '/config/file/comment_close'))
501
+ elsif Etch.xmlfindfirst(@defaults_xml, '/config/file/comment_close')
502
+ comment_close = Etch.xmltext(Etch.xmlfindfirst(@defaults_xml, '/config/file/comment_close'))
503
+ end
504
+ if comment_close && !comment_close.empty?
505
+ warning << comment_close << "\n"
506
+ end
507
+
508
+ # By default we insert the warning at the top of the
509
+ # generated file. However, some files (particularly
510
+ # scripts) have a special first line. The user can flag
511
+ # those files to have the warning inserted starting at the
512
+ # second line.
513
+ if !Etch.xmlfindfirst(config_xml, '/config/file/warning_on_second_line')
514
+ # And then other files (notably Solaris crontabs) can't
515
+ # have any blank lines. Normally we insert a blank
516
+ # line between the warning message and the generated
517
+ # file to improve readability. The user can flag us to
518
+ # not insert that blank line.
519
+ if !Etch.xmlfindfirst(config_xml, '/config/file/no_space_around_warning')
520
+ newcontents = warning + "\n" + newcontents
521
+ else
522
+ newcontents = warning + newcontents
523
+ end
524
+ else
525
+ parts = newcontents.split("\n", 2)
526
+ if !Etch.xmlfindfirst(config_xml, '/config/file/no_space_around_warning')
527
+ newcontents = parts[0] << "\n\n" << warning << "\n" << parts[1]
528
+ else
529
+ newcontents = parts[0] << warning << parts[1]
530
+ end
531
+ end
532
+ end # if warning_file
533
+
534
+ # Add the generated file contents to the XML
535
+ Etch.xmladd(config_xml, '/config/file', 'contents', Base64.encode64(newcontents))
536
+ end
537
+
538
+ # Remove the source configuration from the XML, the
539
+ # client won't need to see it
540
+ Etch.xmlremovepath(config_xml, '/config/file/source')
541
+
542
+ # Remove all of the warning related elements from the XML, the
543
+ # client won't need to see them
544
+ Etch.xmlremovepath(config_xml, '/config/file/warning_file')
545
+ Etch.xmlremovepath(config_xml, '/config/file/warning_on_second_line')
546
+ Etch.xmlremovepath(config_xml, '/config/file/no_space_around_warning')
547
+ Etch.xmlremovepath(config_xml, '/config/file/comment_open')
548
+ Etch.xmlremovepath(config_xml, '/config/file/comment_line')
549
+ Etch.xmlremovepath(config_xml, '/config/file/comment_close')
550
+
551
+ # If the XML doesn't contain ownership and permissions entries
552
+ # then add appropriate ones based on the defaults
553
+ if !Etch.xmlfindfirst(config_xml, '/config/file/owner')
554
+ if Etch.xmlfindfirst(@defaults_xml, '/config/file/owner')
555
+ Etch.xmlcopyelem(
556
+ Etch.xmlfindfirst(@defaults_xml, '/config/file/owner'),
557
+ Etch.xmlfindfirst(config_xml, '/config/file'))
558
+ else
559
+ raise "defaults.xml needs /config/file/owner"
560
+ end
561
+ end
562
+ if !Etch.xmlfindfirst(config_xml, '/config/file/group')
563
+ if Etch.xmlfindfirst(@defaults_xml, '/config/file/group')
564
+ Etch.xmlcopyelem(
565
+ Etch.xmlfindfirst(@defaults_xml, '/config/file/group'),
566
+ Etch.xmlfindfirst(config_xml, '/config/file'))
567
+ else
568
+ raise "defaults.xml needs /config/file/group"
569
+ end
570
+ end
571
+ if !Etch.xmlfindfirst(config_xml, '/config/file/perms')
572
+ if Etch.xmlfindfirst(@defaults_xml, '/config/file/perms')
573
+ Etch.xmlcopyelem(
574
+ Etch.xmlfindfirst(@defaults_xml, '/config/file/perms'),
575
+ Etch.xmlfindfirst(config_xml, '/config/file'))
576
+ else
577
+ raise "defaults.xml needs /config/file/perms"
578
+ end
579
+ end
580
+
581
+ # Send the file contents and metadata to the client
582
+ filter_xml!(config_xml, ['file'])
583
+
584
+ generation_status = :success
585
+ throw :generate_done
586
+ end
587
+ end
588
+
589
+ #
590
+ # Symbolic link
591
+ #
592
+
593
+ if Etch.xmlfindfirst(config_xml, '/config/link')
594
+ dest = nil
595
+
596
+ if Etch.xmlfindfirst(config_xml, '/config/link/dest')
597
+ dest_elements = Etch.xmlarray(config_xml, '/config/link/dest')
598
+ if check_for_inconsistency(dest_elements)
599
+ raise "Inconsistent 'dest' entries for #{file}"
600
+ end
601
+
602
+ dest = Etch.xmltext(dest_elements.first)
603
+ elsif Etch.xmlfindfirst(config_xml, '/config/link/script')
604
+ # The user can specify a script to perform more complex
605
+ # testing to decide whether to create the link or not and
606
+ # what its destination should be.
607
+
608
+ script_elements = Etch.xmlarray(config_xml, '/config/link/script')
609
+ if check_for_inconsistency(script_elements)
610
+ raise "Inconsistent 'script' entries for #{file}"
611
+ end
612
+
613
+ script = Etch.xmltext(script_elements.first)
614
+ external = EtchExternalSource.new(file, original_file, @facts, @groups, local_requests, @sourcebase, @commandsbase, @sitelibbase, @dlogger)
615
+ dest = external.run_script(script)
616
+
617
+ # Remove the script element(s) from the XML, the client won't need
618
+ # to see them
619
+ script_elements.each { |se| Etch.xmlremove(config_xml, se) }
620
+ else
621
+ # If the filtering has removed the destination for the link,
622
+ # that means it doesn't apply to this node.
623
+ @dlogger.debug "No configuration for link #{file} destination, doing nothing"
624
+ end
625
+
626
+ if !dest || dest.empty?
627
+ @dlogger.debug "Destination for link #{file} empty, doing nothing"
628
+ else
629
+ # If there isn't a dest element in the XML (if the user used a
630
+ # script) then insert one for the benefit of the client
631
+ if !Etch.xmlfindfirst(config_xml, '/config/link/dest')
632
+ Etch.xmladd(config_xml, '/config/link', 'dest', dest)
633
+ end
634
+
635
+ # If the XML doesn't contain ownership and permissions entries
636
+ # then add appropriate ones based on the defaults
637
+ if !Etch.xmlfindfirst(config_xml, '/config/link/owner')
638
+ if Etch.xmlfindfirst(@defaults_xml, '/config/link/owner')
639
+ Etch.xmlcopyelem(
640
+ Etch.xmlfindfirst(@defaults_xml, '/config/link/owner'),
641
+ Etch.xmlfindfirst(config_xml, '/config/link'))
642
+ else
643
+ raise "defaults.xml needs /config/link/owner"
644
+ end
645
+ end
646
+ if !Etch.xmlfindfirst(config_xml, '/config/link/group')
647
+ if Etch.xmlfindfirst(@defaults_xml, '/config/link/group')
648
+ Etch.xmlcopyelem(
649
+ Etch.xmlfindfirst(@defaults_xml, '/config/link/group'),
650
+ Etch.xmlfindfirst(config_xml, '/config/link'))
651
+ else
652
+ raise "defaults.xml needs /config/link/group"
653
+ end
654
+ end
655
+ if !Etch.xmlfindfirst(config_xml, '/config/link/perms')
656
+ if Etch.xmlfindfirst(@defaults_xml, '/config/link/perms')
657
+ Etch.xmlcopyelem(
658
+ Etch.xmlfindfirst(@defaults_xml, '/config/link/perms'),
659
+ Etch.xmlfindfirst(config_xml, '/config/link'))
660
+ else
661
+ raise "defaults.xml needs /config/link/perms"
662
+ end
663
+ end
664
+
665
+ # Send the file contents and metadata to the client
666
+ filter_xml!(config_xml, ['link'])
667
+
668
+ generation_status = :success
669
+ throw :generate_done
670
+ end
671
+ end
672
+
673
+ #
674
+ # Directory
675
+ #
676
+
677
+ if Etch.xmlfindfirst(config_xml, '/config/directory')
678
+ create = false
679
+ if Etch.xmlfindfirst(config_xml, '/config/directory/create')
680
+ create = true
681
+ elsif Etch.xmlfindfirst(config_xml, '/config/directory/script')
682
+ # The user can specify a script to perform more complex testing
683
+ # to decide whether to create the directory or not.
684
+ script_elements = Etch.xmlarray(config_xml, '/config/directory/script')
685
+ if check_for_inconsistency(script_elements)
686
+ raise "Inconsistent 'script' entries for #{file}"
687
+ end
688
+
689
+ script = Etch.xmltext(script_elements.first)
690
+ external = EtchExternalSource.new(file, original_file, @facts, @groups, local_requests, @sourcebase, @commandsbase, @sitelibbase, @dlogger)
691
+ create = external.run_script(script)
692
+ create = false if create.empty?
693
+
694
+ # Remove the script element(s) from the XML, the client won't need
695
+ # to see them
696
+ script_elements.each { |se| Etch.xmlremove(config_xml, se) }
697
+ else
698
+ # If the filtering has removed the directive to create this
699
+ # directory, that means it doesn't apply to this node.
700
+ @dlogger.debug "No configuration to create directory #{file}, doing nothing"
701
+ end
702
+
703
+ if !create
704
+ @dlogger.debug "Directive to create directory #{file} false, doing nothing"
705
+ else
706
+ # If there isn't a create element in the XML (if the user used a
707
+ # script) then insert one for the benefit of the client
708
+ if !Etch.xmlfindfirst(config_xml, '/config/directory/create')
709
+ Etch.xmladd(config_xml, '/config/directory', 'create', nil)
710
+ end
711
+
712
+ # If the XML doesn't contain ownership and permissions entries
713
+ # then add appropriate ones based on the defaults
714
+ if !Etch.xmlfindfirst(config_xml, '/config/directory/owner')
715
+ if Etch.xmlfindfirst(@defaults_xml, '/config/directory/owner')
716
+ Etch.xmlcopyelem(
717
+ Etch.xmlfindfirst(@defaults_xml, '/config/directory/owner'),
718
+ Etch.xmlfindfirst(config_xml, '/config/directory'))
719
+ else
720
+ raise "defaults.xml needs /config/directory/owner"
721
+ end
722
+ end
723
+ if !Etch.xmlfindfirst(config_xml, '/config/directory/group')
724
+ if Etch.xmlfindfirst(@defaults_xml, '/config/directory/group')
725
+ Etch.xmlcopyelem(
726
+ Etch.xmlfindfirst(@defaults_xml, '/config/directory/group'),
727
+ Etch.xmlfindfirst(config_xml, '/config/directory'))
728
+ else
729
+ raise "defaults.xml needs /config/directory/group"
730
+ end
731
+ end
732
+ if !Etch.xmlfindfirst(config_xml, '/config/directory/perms')
733
+ if Etch.xmlfindfirst(@defaults_xml, '/config/directory/perms')
734
+ Etch.xmlcopyelem(
735
+ Etch.xmlfindfirst(@defaults_xml, '/config/directory/perms'),
736
+ Etch.xmlfindfirst(config_xml, '/config/directory'))
737
+ else
738
+ raise "defaults.xml needs /config/directory/perms"
739
+ end
740
+ end
741
+
742
+ # Send the file contents and metadata to the client
743
+ filter_xml!(config_xml, ['directory'])
744
+
745
+ generation_status = :success
746
+ throw :generate_done
747
+ end
748
+ end
749
+
750
+ #
751
+ # Delete whatever is there
752
+ #
753
+
754
+ if Etch.xmlfindfirst(config_xml, '/config/delete')
755
+ proceed = false
756
+ if Etch.xmlfindfirst(config_xml, '/config/delete/proceed')
757
+ proceed = true
758
+ elsif Etch.xmlfindfirst(config_xml, '/config/delete/script')
759
+ # The user can specify a script to perform more complex testing
760
+ # to decide whether to delete the file or not.
761
+ script_elements = Etch.xmlarray(config_xml, '/config/delete/script')
762
+ if check_for_inconsistency(script_elements)
763
+ raise "Inconsistent 'script' entries for #{file}"
764
+ end
765
+
766
+ script = Etch.xmltext(script_elements.first)
767
+ external = EtchExternalSource.new(file, original_file, @facts, @groups, local_requests, @sourcebase, @commandsbase, @sitelibbase, @dlogger)
768
+ proceed = external.run_script(script)
769
+ proceed = false if proceed.empty?
770
+
771
+ # Remove the script element(s) from the XML, the client won't need
772
+ # to see them
773
+ script_elements.each { |se| Etch.xmlremove(config_xml, se) }
774
+ else
775
+ # If the filtering has removed the directive to remove this
776
+ # file, that means it doesn't apply to this node.
777
+ @dlogger.debug "No configuration to delete #{file}, doing nothing"
778
+ end
779
+
780
+ if !proceed
781
+ @dlogger.debug "Directive to delete #{file} false, doing nothing"
782
+ else
783
+ # If there isn't a proceed element in the XML (if the user used a
784
+ # script) then insert one for the benefit of the client
785
+ if !Etch.xmlfindfirst(config_xml, '/config/delete/proceed')
786
+ Etch.xmladd(config_xml, '/config/delete', 'proceed', nil)
787
+ end
788
+
789
+ # Send the file contents and metadata to the client
790
+ filter_xml!(config_xml, ['delete'])
791
+
792
+ generation_status = :success
793
+ throw :generate_done
794
+ end
795
+ end
796
+ end
797
+
798
+ # In addition to successful configs return configs for files that need
799
+ # orig data (generation_status==false) because any setup commands might be
800
+ # needed to create the original file.
801
+ if generation_status != :unknown &&
802
+ Etch.xmlfindfirst(config_xml, '/config/*')
803
+ # The client needs this attribute to know to which file
804
+ # this chunk of XML refers
805
+ Etch.xmlattradd(Etch.xmlroot(config_xml), 'filename', file)
806
+ @configs[file] = config_xml
807
+ end
808
+
809
+ @already_generated[file] = true
810
+ @filestack.delete(file)
811
+ @generation_status[file] = generation_status
812
+
813
+ generation_status
814
+ end
815
+
816
+ # Returns the value of the generation_status variable, see comments in
817
+ # method for possible values.
818
+ def generate_commands(command, request)
819
+ # Skip commands we've already generated in response to <depend>
820
+ # statements.
821
+ if @already_generated[command]
822
+ @dlogger.debug "Skipping already generated command #{command}"
823
+ return
824
+ end
825
+
826
+ # Check for circular dependencies, otherwise we're vulnerable
827
+ # to going into an infinite loop
828
+ if @filestack[command]
829
+ raise "Circular dependency detected for command #{command}"
830
+ end
831
+ @filestack[command] = true
832
+
833
+ commands_xml_file = File.join(@commandsbase, command, 'commands.xml')
834
+ if !File.exist?(commands_xml_file)
835
+ raise "commands.xml for #{command} does not exist"
836
+ end
837
+
838
+ # Load the commands.xml file
839
+ commands_xml = Etch.xmlload(commands_xml_file)
840
+
841
+ # Filter the commands.xml file by looking for attributes
842
+ begin
843
+ configfilter!(Etch.xmlroot(commands_xml))
844
+ rescue Exception => e
845
+ raise e.exception("Error filtering commands.xml for #{command}:\n" + e.message)
846
+ end
847
+
848
+ # Validate the filtered file against commands.dtd
849
+ if !Etch.xmlvalidate(commands_xml, @commands_dtd)
850
+ raise "Filtered commands.xml for #{command} fails validation"
851
+ end
852
+
853
+ generation_status = :unknown
854
+ # As we go through the process of generating the command we'll end up with
855
+ # four possible outcomes:
856
+ # fatal error: raise an exception
857
+ # failure: we're missing needed data for this command or a dependency,
858
+ # generally the original file for a file this command depends on
859
+ # success: we successfully processed a valid configuration
860
+ # unknown: no valid configuration nor errors encountered, probably because
861
+ # filtering removed everything from the commands.xml file. This
862
+ # should be considered a successful outcome, it indicates the
863
+ # caller/client provided us with all required data and our result
864
+ # is that no action needs to be taken.
865
+ # We keep track of which of the failure, success or unknown states we end
866
+ # up in via the generation_status variable. We initialize it to :unknown.
867
+ # If we encounter either failure or success we set it to false or :success.
868
+ catch :generate_done do
869
+ # Generate any other commands that this command depends on
870
+ dependfiles = []
871
+ proceed = true
872
+ Etch.xmleach(commands_xml, '/commands/depend') do |depend|
873
+ @dlogger.debug "Generating command dependency #{Etch.xmltext(depend)}"
874
+ r = generate_commands(Etch.xmltext(depend), request)
875
+ proceed = proceed && r
876
+ end
877
+ # Also generate any files that this command depends on
878
+ Etch.xmleach(commands_xml, '/commands/dependfile') do |dependfile|
879
+ @dlogger.debug "Generating file dependency #{Etch.xmltext(dependfile)}"
880
+ dependfiles << Etch.xmltext(dependfile)
881
+ r = generate_file(Etch.xmltext(dependfile), request)
882
+ proceed = proceed && r
883
+ end
884
+ if !proceed
885
+ @dlogger.debug "One or more dependencies of #{command} need data from client"
886
+ # If any file dependency failed to generate (due to a need for orig
887
+ # contents from the client) then we need to tell the client to request
888
+ # all of the files in the dependency tree again. See the big comment
889
+ # in generate_file for further explanation.
890
+ dependfiles.each { |dependfile| @need_orig[dependfile] = true }
891
+ # Try again next time
892
+ @retrycommands[command] = true
893
+ generation_status = false
894
+ throw :generate_done
895
+ end
896
+
897
+ # Change into the corresponding directory so that the user can
898
+ # refer to source files and scripts by their relative pathnames.
899
+ Dir::chdir "#{@commandsbase}/#{command}"
900
+
901
+ # Check that the resulting document is consistent after filtering
902
+ remove = []
903
+ Etch.xmleach(commands_xml, '/commands/step') do |step|
904
+ guard_exec_elements = Etch.xmlarray(step, 'guard/exec')
905
+ if check_for_inconsistency(guard_exec_elements)
906
+ raise "Inconsistent guard 'exec' entries for #{command}: " +
907
+ guard_exec_elements.collect {|elem| Etch.xmltext(elem)}.join(',')
908
+ end
909
+ command_exec_elements = Etch.xmlarray(step, 'command/exec')
910
+ if check_for_inconsistency(command_exec_elements)
911
+ raise "Inconsistent command 'exec' entries for #{command}: " +
912
+ command_exec_elements.collect {|elem| Etch.xmltext(elem)}.join(',')
913
+ end
914
+ # If filtering has removed both the guard and command elements
915
+ # we can remove this step.
916
+ if guard_exec_elements.empty? && command_exec_elements.empty?
917
+ remove << step
918
+ # If filtering has removed the guard but not the command or vice
919
+ # versa that's an error.
920
+ elsif guard_exec_elements.empty?
921
+ raise "Filtering removed guard, but left command: " +
922
+ Etch.xmltext(command_exec_elements.first)
923
+ elsif command_exec_elements.empty?
924
+ raise "Filtering removed command, but left guard: " +
925
+ Etch.xmltext(guard_exec_elements.first)
926
+ end
927
+ end
928
+ remove.each { |elem| Etch.xmlremove(commands_xml, elem) }
929
+
930
+ # I'm not sure if we'd benefit from further checking the XML for
931
+ # validity. For now we declare success if we got this far.
932
+ generation_status = :success
933
+ end
934
+
935
+ # If filtering didn't remove all the content then add this to the list of
936
+ # commands to be returned to the client.
937
+ if generation_status && generation_status != :unknown &&
938
+ Etch.xmlfindfirst(commands_xml, '/commands/*')
939
+ # Include the commands directory name to aid troubleshooting on the
940
+ # client side.
941
+ Etch.xmlattradd(Etch.xmlroot(commands_xml), 'commandname', command)
942
+ @allcommands[command] = commands_xml
943
+ end
944
+
945
+ @already_generated[command] = true
946
+ @filestack.delete(command)
947
+ @generation_status[command] = generation_status
948
+
949
+ generation_status
950
+ end
951
+
952
+ ALWAYS_KEEP = ['depend', 'setup', 'pre', 'test_before_post', 'post', 'test']
953
+ def filter_xml_completely!(config_xml, keepers=[])
954
+ remove = []
955
+ Etch.xmleachall(config_xml) do |elem|
956
+ if !keepers.include?(elem.name)
957
+ remove << elem
958
+ end
959
+ end
960
+ remove.each { |elem| Etch.xmlremove(config_xml, elem) }
961
+ # FIXME: strip comments
962
+ end
963
+ def filter_xml!(config_xml, keepers=[])
964
+ filter_xml_completely!(config_xml, keepers.concat(ALWAYS_KEEP))
965
+ end
966
+
967
+ def configfilter!(element)
968
+ elem_remove = []
969
+ Etch.xmleachall(element) do |elem|
970
+ catch :next_element do
971
+ attr_remove = []
972
+ Etch.xmleachattrall(elem) do |attribute|
973
+ if !check_attribute(attribute.name, attribute.value)
974
+ elem_remove << elem
975
+ throw :next_element
976
+ else
977
+ attr_remove << attribute
978
+ end
979
+ end
980
+ attr_remove.each { |attribute| Etch.xmlattrremove(element, attribute) }
981
+ # Then check any children of this element
982
+ configfilter!(elem)
983
+ end
984
+ end
985
+ elem_remove.each { |elem| Etch.xmlremove(element, elem) }
986
+ end
987
+
988
+ # Used when parsing each config.xml to filter out any elements which
989
+ # don't match the configuration of this node. If the attribute matches
990
+ # then we just remove the attribute but leave the element it is attached
991
+ # to. If the attribute doesn't match then we remove the entire element.
992
+ #
993
+ # Things we'd like to do in the config.xml files:
994
+ # Implemented:
995
+ # - Negation (!)
996
+ # - Numerical comparisons (<, <=, =>, >)
997
+ # - Regular expressions (//)
998
+ # Not yet:
999
+ # - Flow control (if/else)
1000
+ def check_attribute(name, value)
1001
+ comparables = []
1002
+ if name == 'group'
1003
+ comparables = @groups
1004
+ elsif @facts[name]
1005
+ comparables = [@facts[name]]
1006
+ end
1007
+
1008
+ result = false
1009
+ negate = false
1010
+
1011
+ # Negation
1012
+ # i.e. <plain os="!SunOS"></plain>
1013
+ if value =~ /^\!/
1014
+ negate = true
1015
+ value.sub!(/^\!/, '') # Strip off the bang
1016
+ end
1017
+
1018
+ comparables.each do |comp|
1019
+ # Numerical comparisons
1020
+ # i.e. <plain os="SunOS" osversion=">=5.8"></plain>
1021
+ # Note that the standard for XML requires that the < character be
1022
+ # escaped in attribute values, so you have to use &lt;
1023
+ # That's been decoded by the XML parser before it gets to us
1024
+ # here so we don't have to handle it specially
1025
+ if value =~ %r{^(<|<=|>=|>)\s*([\d\.]+)$}
1026
+ operator = $1
1027
+ valueversion = Version.new($2)
1028
+ compversion = Version.new(comp)
1029
+ if compversion.send(operator.to_sym, valueversion)
1030
+ result = true
1031
+ end
1032
+ # Regular expressions
1033
+ # i.e. <plain group="/dns-.*-server/"></plain>
1034
+ # or <plain os="/Red Hat/"></plain>
1035
+ elsif value =~ %r{^/(.*)/$}
1036
+ regexp = Regexp.new($1)
1037
+ if comp =~ regexp
1038
+ result = true
1039
+ end
1040
+ else
1041
+ if comp == value
1042
+ result = true
1043
+ end
1044
+ end
1045
+ end
1046
+
1047
+ if negate
1048
+ return !result
1049
+ else
1050
+ return result
1051
+ end
1052
+ end
1053
+
1054
+ # We let users specify a source multiple times in a config.xml. This is
1055
+ # necessary if multiple groups require the same file, for example.
1056
+ # However, the user needs to be consistent. So this is valid on a
1057
+ # machine that is providing both groups:
1058
+ #
1059
+ # <plain group="foo_server">source_file</plain>
1060
+ # <plain group="bar_server">source_file</plain>
1061
+ #
1062
+ # But this isn't valid on the same machine. Which of the two files
1063
+ # should we use?
1064
+ #
1065
+ # <plain group="foo_server">source_file</plain>
1066
+ # <plain group="bar_server">different_source_file</plain>
1067
+ #
1068
+ # This subroutine checks a list of XML elements to determine if they all
1069
+ # contain the same value. Returns true if there is inconsistency.
1070
+ def check_for_inconsistency(elements)
1071
+ elements_as_text = elements.collect { |elem| Etch.xmltext(elem) }
1072
+ if elements_as_text.uniq.length > 1
1073
+ return true
1074
+ else
1075
+ return false
1076
+ end
1077
+ end
1078
+
1079
+ def self.xmlnewdoc
1080
+ case @@xmllib
1081
+ when :libxml
1082
+ LibXML::XML::Document.new
1083
+ when :rexml
1084
+ REXML::Document.new
1085
+ else
1086
+ raise "Unknown @xmllib #{@xmllib}"
1087
+ end
1088
+ end
1089
+
1090
+ def self.xmlroot(doc)
1091
+ case @@xmllib
1092
+ when :libxml
1093
+ doc.root
1094
+ when :rexml
1095
+ doc.root
1096
+ else
1097
+ raise "Unknown @xmllib #{@xmllib}"
1098
+ end
1099
+ end
1100
+
1101
+ def self.xmlsetroot(doc, root)
1102
+ case @@xmllib
1103
+ when :libxml
1104
+ doc.root = root
1105
+ when :rexml
1106
+ doc << root
1107
+ else
1108
+ raise "Unknown @xmllib #{@xmllib}"
1109
+ end
1110
+ end
1111
+
1112
+ def self.xmlload(file)
1113
+ case @@xmllib
1114
+ when :libxml
1115
+ LibXML::XML::Document.file(file)
1116
+ when :rexml
1117
+ REXML::Document.new(File.open(file))
1118
+ else
1119
+ raise "Unknown @xmllib #{@xmllib}"
1120
+ end
1121
+ end
1122
+
1123
+ def self.xmlloaddtd(dtdfile)
1124
+ case @@xmllib
1125
+ when :libxml
1126
+ LibXML::XML::Dtd.new(IO.read(dtdfile))
1127
+ when :rexml
1128
+ nil
1129
+ else
1130
+ raise "Unknown @xmllib #{@xmllib}"
1131
+ end
1132
+ end
1133
+
1134
+ def self.xmlvalidate(xmldoc, dtd)
1135
+ case @@xmllib
1136
+ when :libxml
1137
+ xmldoc.validate(dtd)
1138
+ when :rexml
1139
+ true
1140
+ else
1141
+ raise "Unknown @xmllib #{@xmllib}"
1142
+ end
1143
+ end
1144
+
1145
+ def self.xmlnewelem(name)
1146
+ case @@xmllib
1147
+ when :libxml
1148
+ LibXML::XML::Node.new(name)
1149
+ when :rexml
1150
+ REXML::Element.new(name)
1151
+ else
1152
+ raise "Unknown @xmllib #{@xmllib}"
1153
+ end
1154
+ end
1155
+
1156
+ def self.xmleach(xmldoc, xpath, &block)
1157
+ case @@xmllib
1158
+ when :libxml
1159
+ xmldoc.find(xpath).each(&block)
1160
+ when :rexml
1161
+ xmldoc.elements.each(xpath, &block)
1162
+ else
1163
+ raise "Unknown @xmllib #{@xmllib}"
1164
+ end
1165
+ end
1166
+
1167
+ def self.xmleachall(xmldoc, &block)
1168
+ case @@xmllib
1169
+ when :libxml
1170
+ if xmldoc.kind_of?(LibXML::XML::Document)
1171
+ xmldoc.root.each_element(&block)
1172
+ else
1173
+ xmldoc.each_element(&block)
1174
+ end
1175
+ when :rexml
1176
+ if xmldoc.node_type == :document
1177
+ xmldoc.root.elements.each(&block)
1178
+ else
1179
+ xmldoc.elements.each(&block)
1180
+ end
1181
+ else
1182
+ raise "Unknown @xmllib #{@xmllib}"
1183
+ end
1184
+ end
1185
+
1186
+ def self.xmleachattrall(elem, &block)
1187
+ case @@xmllib
1188
+ when :libxml
1189
+ elem.attributes.each(&block)
1190
+ when :rexml
1191
+ elem.attributes.each_attribute(&block)
1192
+ else
1193
+ raise "Unknown @xmllib #{@xmllib}"
1194
+ end
1195
+ end
1196
+
1197
+ def self.xmlarray(xmldoc, xpath)
1198
+ case @@xmllib
1199
+ when :libxml
1200
+ elements = xmldoc.find(xpath)
1201
+ if elements
1202
+ elements.to_a
1203
+ else
1204
+ []
1205
+ end
1206
+ when :rexml
1207
+ xmldoc.elements.to_a(xpath)
1208
+ else
1209
+ raise "Unknown @xmllib #{@xmllib}"
1210
+ end
1211
+ end
1212
+
1213
+ def self.xmlfindfirst(xmldoc, xpath)
1214
+ case @@xmllib
1215
+ when :libxml
1216
+ xmldoc.find_first(xpath)
1217
+ when :rexml
1218
+ xmldoc.elements[xpath]
1219
+ else
1220
+ raise "Unknown @xmllib #{@xmllib}"
1221
+ end
1222
+ end
1223
+
1224
+ def self.xmltext(elem)
1225
+ case @@xmllib
1226
+ when :libxml
1227
+ elem.content
1228
+ when :rexml
1229
+ text = elem.text
1230
+ # REXML returns nil rather than '' if there is no text
1231
+ if text
1232
+ text
1233
+ else
1234
+ ''
1235
+ end
1236
+ else
1237
+ raise "Unknown @xmllib #{@xmllib}"
1238
+ end
1239
+ end
1240
+
1241
+ def self.xmlsettext(elem, text)
1242
+ case @@xmllib
1243
+ when :libxml
1244
+ elem.content = text
1245
+ when :rexml
1246
+ elem.text = text
1247
+ else
1248
+ raise "Unknown @xmllib #{@xmllib}"
1249
+ end
1250
+ end
1251
+
1252
+ def self.xmladd(xmldoc, xpath, name, contents=nil)
1253
+ case @@xmllib
1254
+ when :libxml
1255
+ elem = LibXML::XML::Node.new(name)
1256
+ if contents
1257
+ elem.content = contents
1258
+ end
1259
+ xmldoc.find_first(xpath) << elem
1260
+ elem
1261
+ when :rexml
1262
+ elem = REXML::Element.new(name)
1263
+ if contents
1264
+ elem.text = contents
1265
+ end
1266
+ xmldoc.elements[xpath].add_element(elem)
1267
+ elem
1268
+ else
1269
+ raise "Unknown @xmllib #{@xmllib}"
1270
+ end
1271
+ end
1272
+
1273
+ def self.xmlcopyelem(elem, destelem)
1274
+ case @@xmllib
1275
+ when :libxml
1276
+ destelem << elem.copy(true)
1277
+ when :rexml
1278
+ destelem.add_element(elem.dup)
1279
+ else
1280
+ raise "Unknown @xmllib #{@xmllib}"
1281
+ end
1282
+ end
1283
+
1284
+ def self.xmlremove(xmldoc, element)
1285
+ case @@xmllib
1286
+ when :libxml
1287
+ element.remove!
1288
+ when :rexml
1289
+ if xmldoc.node_type == :document
1290
+ xmldoc.root.elements.delete(element)
1291
+ else
1292
+ xmldoc.elements.delete(element)
1293
+ end
1294
+ else
1295
+ raise "Unknown @xmllib #{@xmllib}"
1296
+ end
1297
+ end
1298
+
1299
+ def self.xmlremovepath(xmldoc, xpath)
1300
+ case @@xmllib
1301
+ when :libxml
1302
+ xmldoc.find(xpath).each { |elem| elem.remove! }
1303
+ when :rexml
1304
+ xmldoc.delete_element(xpath)
1305
+ else
1306
+ raise "Unknown @xmllib #{@xmllib}"
1307
+ end
1308
+ end
1309
+
1310
+ def self.xmlattradd(elem, attrname, attrvalue)
1311
+ case @@xmllib
1312
+ when :libxml
1313
+ elem.attributes[attrname] = attrvalue
1314
+ when :rexml
1315
+ elem.add_attribute(attrname, attrvalue)
1316
+ else
1317
+ raise "Unknown @xmllib #{@xmllib}"
1318
+ end
1319
+ end
1320
+
1321
+ def self.xmlattrremove(elem, attribute)
1322
+ case @@xmllib
1323
+ when :libxml
1324
+ attribute.remove!
1325
+ when :rexml
1326
+ elem.attributes.delete(attribute)
1327
+ else
1328
+ raise "Unknown @xmllib #{@xmllib}"
1329
+ end
1330
+ end
1331
+ end
1332
+
1333
+ class EtchExternalSource
1334
+ def initialize(file, original_file, facts, groups, local_requests, sourcebase, commandsbase, sitelibbase, dlogger)
1335
+ # The external source is going to be processed within the same Ruby
1336
+ # instance as etch. We want to make it clear what variables we are
1337
+ # intentionally exposing to external sources, essentially this
1338
+ # defines the "API" for those external sources.
1339
+ @file = file
1340
+ @original_file = original_file
1341
+ @facts = facts
1342
+ @groups = groups
1343
+ @local_requests = local_requests
1344
+ @sourcebase = sourcebase
1345
+ @commandsbase = commandsbase
1346
+ @sitelibbase = sitelibbase
1347
+ @dlogger = dlogger
1348
+ end
1349
+
1350
+ # This method processes an ERB template (as specified via a <template>
1351
+ # entry in a config.xml file) and returns the results.
1352
+ def process_template(template)
1353
+ @dlogger.debug "Processing template #{template} for file #{@file}"
1354
+ # The '-' arg allows folks to use <% -%> or <%- -%> to instruct ERB to
1355
+ # not insert a newline for that line, which helps avoid a bunch of blank
1356
+ # lines in the processed file where there was code in the template.
1357
+ erb = ERB.new(IO.read(template), nil, '-')
1358
+ # The binding arg ties the template's namespace to this point in the
1359
+ # code, thus ensuring that all of the variables above (@file, etc.)
1360
+ # are visible to the template code.
1361
+ begin
1362
+ erb.result(binding)
1363
+ rescue Exception => e
1364
+ # Help the user figure out where the exception occurred, otherwise they
1365
+ # just get told it happened here, which isn't very helpful.
1366
+ raise e.exception("Exception while processing template #{template} for file #{@file}:\n" + e.message)
1367
+ end
1368
+ end
1369
+
1370
+ # This method runs a etch script (as specified via a <script> entry
1371
+ # in a config.xml file) and returns any output that the script puts in
1372
+ # the @contents variable.
1373
+ def run_script(script)
1374
+ @dlogger.debug "Processing script #{script} for file #{@file}"
1375
+ @contents = ''
1376
+ begin
1377
+ eval(IO.read(script))
1378
+ rescue Exception => e
1379
+ if e.kind_of?(SystemExit)
1380
+ # The user might call exit within a script. We want the scripts
1381
+ # to act as much like a real script as possible, so ignore those.
1382
+ else
1383
+ # Help the user figure out where the exception occurred, otherwise they
1384
+ # just get told it happened here in eval, which isn't very helpful.
1385
+ raise e.exception("Exception while processing script #{script} for file #{@file}:\n" + e.message)
1386
+ end
1387
+ end
1388
+ @contents
1389
+ end
1390
+ end
1391
+