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/Rakefile +16 -0
- data/bin/etch +101 -0
- data/bin/etch_cron_wrapper +18 -0
- data/bin/etch_to_trunk +45 -0
- data/etc/ca.pem +1 -0
- data/etc/dhparams +9 -0
- data/lib/etch.rb +1391 -0
- data/lib/etchclient.rb +2420 -0
- data/lib/versiontype.rb +84 -0
- data/man/man8/etch.8 +204 -0
- metadata +78 -0
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 <
|
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
|
+
|