etch 3.12

Sign up to get free protection for your applications and to get access to all the features.
data/lib/etchclient.rb ADDED
@@ -0,0 +1,2420 @@
1
+ ##############################################################################
2
+ # Etch configuration file management tool library
3
+ ##############################################################################
4
+
5
+ # Ensure we can find etch.rb if run within the development directory structure
6
+ # This is roughly equivalent to "../server/lib"
7
+ serverlibdir = File.join(File.dirname(File.dirname(File.expand_path(__FILE__))), 'server', 'lib')
8
+ if File.exist?(serverlibdir)
9
+ $:.unshift(serverlibdir)
10
+ end
11
+
12
+ begin
13
+ # Try loading facter w/o gems first so that we don't introduce a
14
+ # dependency on gems if it is not needed.
15
+ require 'facter' # Facter
16
+ rescue LoadError
17
+ require 'rubygems'
18
+ require 'facter'
19
+ end
20
+ require 'find'
21
+ require 'digest/sha1' # hexdigest
22
+ require 'openssl' # OpenSSL
23
+ require 'base64' # decode64, encode64
24
+ require 'uri'
25
+ require 'net/http'
26
+ require 'net/https'
27
+ require 'rexml/document'
28
+ require 'fileutils' # copy, mkpath, rmtree
29
+ require 'fcntl' # Fcntl::O_*
30
+ require 'etc' # getpwnam, getgrnam
31
+ require 'tempfile' # Tempfile
32
+ require 'cgi'
33
+ require 'timeout'
34
+ require 'logger'
35
+ require 'etch'
36
+
37
+ class Etch::Client
38
+ VERSION = '3.12'
39
+
40
+ CONFIRM_PROCEED = 1
41
+ CONFIRM_SKIP = 2
42
+ CONFIRM_QUIT = 3
43
+ PRIVATE_KEY_PATHS = ["/etc/ssh/ssh_host_rsa_key", "/etc/ssh_host_rsa_key"]
44
+ CONFIGDIR = '/etc/etch'
45
+
46
+ # We need these in relation to the output capturing
47
+ ORIG_STDOUT = STDOUT.dup
48
+ ORIG_STDERR = STDERR.dup
49
+
50
+ attr_reader :exec_once_per_run
51
+
52
+ def initialize(options)
53
+ @server = options[:server] ? options[:server] : 'https://etch'
54
+ @tag = options[:tag]
55
+ @varbase = options[:varbase] ? options[:varbase] : '/var/etch'
56
+ @local = options[:local]
57
+ @debug = options[:debug]
58
+ @dryrun = options[:dryrun]
59
+ @interactive = options[:interactive]
60
+ @filenameonly = options[:filenameonly]
61
+ @fullfile = options[:fullfile]
62
+ @key = options[:key] ? options[:key] : get_private_key_path
63
+ @disableforce = options[:disableforce]
64
+ @lockforce = options[:lockforce]
65
+
66
+ # Ensure we have a sane path, particularly since we are often run from
67
+ # cron.
68
+ # FIXME: Read from config file
69
+ ENV['PATH'] = '/bin:/usr/bin:/sbin:/usr/sbin:/opt/csw/bin:/opt/csw/sbin'
70
+
71
+ @origbase = File.join(@varbase, 'orig')
72
+ @historybase = File.join(@varbase, 'history')
73
+ @lockbase = File.join(@varbase, 'locks')
74
+ @requestbase = File.join(@varbase, 'requests')
75
+
76
+ @facts = Facter.to_hash
77
+ if @facts['operatingsystemrelease']
78
+ # Some versions of Facter have a bug that leaves extraneous
79
+ # whitespace on this fact. Work around that with strip. I.e. on
80
+ # CentOS you'll get '5 ' or '5.2 '.
81
+ @facts['operatingsystemrelease'].strip!
82
+ end
83
+
84
+ if @local
85
+ logger = Logger.new(STDOUT)
86
+ dlogger = Logger.new(STDOUT)
87
+ if @debug
88
+ dlogger.level = Logger::DEBUG
89
+ else
90
+ dlogger.level = Logger::INFO
91
+ end
92
+ @etch = Etch.new(logger, dlogger)
93
+ else
94
+ # Make sure the server URL ends in a / so that we can append paths
95
+ # to it using URI.join
96
+ if @server !~ %r{/$}
97
+ @server << '/'
98
+ end
99
+ @filesuri = URI.join(@server, 'files')
100
+ @resultsuri = URI.join(@server, 'results')
101
+
102
+ @blankrequest = {}
103
+ # If the user specified a non-standard key then override the
104
+ # sshrsakey fact so that authentication works
105
+ if @key
106
+ @facts['sshrsakey'] = IO.read(@key+'.pub').chomp.split[1]
107
+ end
108
+ @facts.each_pair { |key, value| @blankrequest["facts[#{key}]"] = value.to_s }
109
+ @blankrequest['fqdn'] = @facts['fqdn']
110
+ if @debug
111
+ @blankrequest['debug'] = '1'
112
+ end
113
+ if @tag
114
+ @blankrequest['tag'] = @tag
115
+ end
116
+ end
117
+
118
+ @locked_files = {}
119
+ @first_update = {}
120
+ @already_processed = {}
121
+ @exec_already_processed = {}
122
+ @exec_once_per_run = {}
123
+ @results = []
124
+ # See start/stop_output_capture for these
125
+ @output_pipes = []
126
+
127
+ @lchown_supported = nil
128
+ @lchmod_supported = nil
129
+ end
130
+
131
+ def process_until_done(files, commands)
132
+ # Our overall status. Will be reported to the server and used as the
133
+ # return value for this method. Command-line clients should use it as
134
+ # their exit value. Zero indicates no errors.
135
+ status = 0
136
+ message = ''
137
+
138
+ # Prep http instance
139
+ http = nil
140
+ if !@local
141
+ http = Net::HTTP.new(@filesuri.host, @filesuri.port)
142
+ if @filesuri.scheme == "https"
143
+ # Eliminate the OpenSSL "using default DH parameters" warning
144
+ if File.exist?(File.join(CONFIGDIR, 'dhparams'))
145
+ dh = OpenSSL::PKey::DH.new(IO.read(File.join(CONFIGDIR, 'dhparams')))
146
+ Net::HTTP.ssl_context_accessor(:tmp_dh_callback)
147
+ http.tmp_dh_callback = proc { dh }
148
+ end
149
+ http.use_ssl = true
150
+ if File.exist?(File.join(CONFIGDIR, 'ca.pem'))
151
+ http.ca_file = File.join(CONFIGDIR, 'ca.pem')
152
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
153
+ elsif File.directory?(File.join(CONFIGDIR, 'ca'))
154
+ http.ca_path = File.join(CONFIGDIR, 'ca')
155
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
156
+ end
157
+ end
158
+ http.start
159
+ end
160
+
161
+ # catch/throw for expected/non-error events that end processing
162
+ # begin/raise for error events that end processing
163
+ catch :stop_processing do
164
+ begin
165
+ enabled, message = check_for_disable_etch_file
166
+ if !enabled
167
+ # 200 is the arbitrarily picked exit value indicating
168
+ # that etch is disabled
169
+ status = 200
170
+ throw :stop_processing
171
+ end
172
+ remove_stale_lock_files
173
+
174
+ # Assemble the initial request
175
+ request = nil
176
+ if @local
177
+ request = {}
178
+ if files && !files.empty?
179
+ request[:files] = {}
180
+ files.each do |file|
181
+ request[:files][file] = {:orig => save_orig(file)}
182
+ local_requests = get_local_requests(file)
183
+ if local_requests
184
+ request[:files][file][:local_requests] = local_requests
185
+ end
186
+ end
187
+ end
188
+ if commands && !commands.empty?
189
+ request[:commands] = {}
190
+ commands.each do |command|
191
+ request[:commands][command] = {}
192
+ end
193
+ end
194
+ else
195
+ request = get_blank_request
196
+ if (files && !files.empty?) || (commands && !commands.empty?)
197
+ if files
198
+ files.each do |file|
199
+ request["files[#{CGI.escape(file)}][sha1sum]"] =
200
+ get_orig_sum(file)
201
+ local_requests = get_local_requests(file)
202
+ if local_requests
203
+ request["files[#{CGI.escape(file)}][local_requests]"] =
204
+ local_requests
205
+ end
206
+ end
207
+ end
208
+ if commands
209
+ commands.each do |command|
210
+ request["commands[#{CGI.escape(command)}]"] = '1'
211
+ end
212
+ end
213
+ else
214
+ request['files[GENERATEALL]'] = '1'
215
+ end
216
+ end
217
+
218
+ #
219
+ # Loop back and forth with the server sending requests for files and
220
+ # responding to the server's requests for original contents or sums
221
+ # it needs
222
+ #
223
+
224
+ Signal.trap('EXIT') do
225
+ STDOUT.reopen(ORIG_STDOUT)
226
+ STDERR.reopen(ORIG_STDERR)
227
+ unlock_all_files
228
+ end
229
+
230
+ 10.times do
231
+ #
232
+ # Send request to server
233
+ #
234
+
235
+ responsedata = {}
236
+ if @local
237
+ results = @etch.generate(@local, @facts, request)
238
+ # FIXME: Etch#generate returns parsed XML using whatever XML
239
+ # library it happens to use. In order to avoid re-parsing
240
+ # the XML we'd have to use the XML abstraction code from Etch
241
+ # everwhere here.
242
+ # Until then re-parse the XML using REXML.
243
+ #responsedata[:configs] = results[:configs]
244
+ responsedata[:configs] = {}
245
+ results[:configs].each {|f,c| responsedata[:configs][f] = REXML::Document.new(c.to_s) }
246
+ responsedata[:need_sums] = {}
247
+ responsedata[:need_origs] = results[:need_orig]
248
+ #responsedata[:allcommands] = results[:allcommands]
249
+ responsedata[:allcommands] = {}
250
+ results[:allcommands].each {|cn,c| responsedata[:allcommands][cn] = REXML::Document.new(c.to_s) }
251
+ responsedata[:retrycommands] = results[:retrycommands]
252
+ else
253
+ puts "Sending request to server #{@filesuri}: #{request.inspect}" if (@debug)
254
+ post = Net::HTTP::Post.new(@filesuri.path)
255
+ post.set_form_data(request)
256
+ sign_post!(post, @key)
257
+ response = http.request(post)
258
+ if !response.kind_of?(Net::HTTPSuccess)
259
+ $stderr.puts response.body
260
+ # error! raises an exception
261
+ response.error!
262
+ end
263
+ puts "Response from server:\n'#{response.body}'" if (@debug)
264
+ if !response.body.nil? && !response.body.empty?
265
+ response_xml = REXML::Document.new(response.body)
266
+ responsedata[:configs] = {}
267
+ response_xml.elements.each('/files/configs/config') do |config|
268
+ file = config.attributes['filename']
269
+ # We have to make a new document so that XPath paths are
270
+ # referenced relative to the configuration for this
271
+ # specific file.
272
+ #responsedata[:configs][file] = REXML::Document.new(response_xml.elements["/files/configs/config[@filename='#{file}']"].to_s)
273
+ responsedata[:configs][file] = REXML::Document.new(config.to_s)
274
+ end
275
+ responsedata[:need_sums] = {}
276
+ response_xml.elements.each('/files/need_sums/need_sum') do |ns|
277
+ responsedata[:need_sums][ns.text] = true
278
+ end
279
+ responsedata[:need_origs] = {}
280
+ response_xml.elements.each('/files/need_origs/need_orig') do |no|
281
+ responsedata[:need_origs][no.text] = true
282
+ end
283
+ responsedata[:allcommands] = {}
284
+ response_xml.elements.each('/files/allcommands/commands') do |command|
285
+ commandname = command.attributes['commandname']
286
+ # We have to make a new document so that XPath paths are
287
+ # referenced relative to the configuration for this
288
+ # specific file.
289
+ #responsedata[:allcommands][commandname] = REXML::Document.new(response_xml.root.elements["/files/allcommands/commands[@commandname='#{commandname}']"].to_s)
290
+ responsedata[:allcommands][commandname] = REXML::Document.new(command.to_s)
291
+ end
292
+ responsedata[:retrycommands] = {}
293
+ response_xml.elements.each('/files/retrycommands/retrycommand') do |rc|
294
+ responsedata[:retrycommands][rc.text] = true
295
+ end
296
+ else
297
+ puts " Response is empty" if (@debug)
298
+ break
299
+ end
300
+ end
301
+
302
+ #
303
+ # Process the response from the server
304
+ #
305
+
306
+ # Prep a clean request hash
307
+ if @local
308
+ request = {}
309
+ if !responsedata[:need_origs].empty?
310
+ request[:files] = {}
311
+ end
312
+ if !responsedata[:retrycommands].empty?
313
+ request[:commands] = {}
314
+ end
315
+ else
316
+ request = get_blank_request
317
+ end
318
+
319
+ # With generateall we expect to make at least two round trips
320
+ # to the server.
321
+ # 1) Send GENERATEALL request, get back a list of need_sums
322
+ # 2) Send sums, possibly get back some need_origs
323
+ # 3) Send origs, get back generated files
324
+ need_to_loop = false
325
+ reset_already_processed
326
+ # Process configs first, as they may contain setup entries that are
327
+ # needed to create the original files.
328
+ responsedata[:configs].each_key do |file|
329
+ puts "Processing config for #{file}" if (@debug)
330
+ continue_processing = process_file(file, responsedata)
331
+ if !continue_processing
332
+ throw :stop_processing
333
+ end
334
+ end
335
+ responsedata[:need_sums].each_key do |need_sum|
336
+ puts "Processing request for sum of #{need_sum}" if (@debug)
337
+ if @local
338
+ # If this happens we screwed something up, the local mode
339
+ # code never requests sums.
340
+ raise "No support for sums in local mode"
341
+ else
342
+ request["files[#{CGI.escape(need_sum)}][sha1sum]"] =
343
+ get_orig_sum(need_sum)
344
+ end
345
+ local_requests = get_local_requests(need_sum)
346
+ if local_requests
347
+ if @local
348
+ request[:files][need_sum][:local_requests] = local_requests
349
+ else
350
+ request["files[#{CGI.escape(need_sum)}][local_requests]"] =
351
+ local_requests
352
+ end
353
+ end
354
+ need_to_loop = true
355
+ end
356
+ responsedata[:need_origs].each_key do |need_orig|
357
+ puts "Processing request for contents of #{need_orig}" if (@debug)
358
+ if @local
359
+ request[:files][need_orig] = {:orig => save_orig(need_orig)}
360
+ else
361
+ request["files[#{CGI.escape(need_orig)}][contents]"] =
362
+ Base64.encode64(get_orig_contents(need_orig))
363
+ request["files[#{CGI.escape(need_orig)}][sha1sum]"] =
364
+ get_orig_sum(need_orig)
365
+ end
366
+ local_requests = get_local_requests(need_orig)
367
+ if local_requests
368
+ if @local
369
+ request[:files][need_orig][:local_requests] = local_requests
370
+ else
371
+ request["files[#{CGI.escape(need_orig)}][local_requests]"] =
372
+ local_requests
373
+ end
374
+ end
375
+ need_to_loop = true
376
+ end
377
+ responsedata[:allcommands].each_key do |commandname|
378
+ puts "Processing commands #{commandname}" if (@debug)
379
+ continue_processing = process_commands(commandname, responsedata)
380
+ if !continue_processing
381
+ throw :stop_processing
382
+ end
383
+ end
384
+ responsedata[:retrycommands].each_key do |commandname|
385
+ puts "Processing request to retry command #{commandname}" if (@debug)
386
+ if @local
387
+ request[:commands][commandname] = true
388
+ else
389
+ request["commands[#{CGI.escape(commandname)}]"] = '1'
390
+ end
391
+ need_to_loop = true
392
+ end
393
+
394
+ if !need_to_loop
395
+ break
396
+ end
397
+ end
398
+
399
+ puts "Processing 'exec once per run' commands" if (!exec_once_per_run.empty?)
400
+ exec_once_per_run.keys.each do |exec|
401
+ process_exec('post', exec)
402
+ end
403
+ rescue Exception => e
404
+ status = 1
405
+ $stderr.puts e.message
406
+ $stderr.puts e.backtrace.join("\n") if @debug
407
+ end # begin/rescue
408
+ end # catch
409
+
410
+ # Send results to server
411
+ if !@dryrun && !@local
412
+ rails_results = []
413
+ # A few of the fields here are numbers or booleans and need a
414
+ # to_s to make them compatible with CGI.escape, which expects a
415
+ # string.
416
+ rails_results << "fqdn=#{CGI.escape(@facts['fqdn'])}"
417
+ rails_results << "status=#{CGI.escape(status.to_s)}"
418
+ rails_results << "message=#{CGI.escape(message)}"
419
+ @results.each do |result|
420
+ # Strangely enough this works. Even though the key is not unique to
421
+ # each result the Rails parameter parsing code keeps track of keys it
422
+ # has seen, and if it sees a duplicate it starts a new hash.
423
+ rails_results << "results[][file]=#{CGI.escape(result['file'])}"
424
+ rails_results << "results[][success]=#{CGI.escape(result['success'].to_s)}"
425
+ rails_results << "results[][message]=#{CGI.escape(result['message'])}"
426
+ end
427
+ puts "Sending results to server #{@resultsuri}" if (@debug)
428
+ resultspost = Net::HTTP::Post.new(@resultsuri.path)
429
+ # We have to bypass Net::HTTP's set_form_data method in this case
430
+ # because it expects a hash and we can't provide the results in the
431
+ # format we want in a hash because we'd have duplicate keys (see above).
432
+ results_as_string = rails_results.join('&')
433
+ resultspost.body = results_as_string
434
+ resultspost.content_type = 'application/x-www-form-urlencoded'
435
+ sign_post!(resultspost, @key)
436
+ response = http.request(resultspost)
437
+ case response
438
+ when Net::HTTPSuccess
439
+ puts "Response from server:\n'#{response.body}'" if (@debug)
440
+ else
441
+ $stderr.puts "Error submitting results:"
442
+ $stderr.puts response.body
443
+ end
444
+ end
445
+
446
+ status
447
+ end
448
+
449
+ def check_for_disable_etch_file
450
+ disable_etch = File.join(@varbase, 'disable_etch')
451
+ message = ''
452
+ if File.exist?(disable_etch)
453
+ if !@disableforce
454
+ message = "Etch disabled:\n"
455
+ message << IO.read(disable_etch)
456
+ puts message
457
+ return false, message
458
+ else
459
+ puts "Ignoring disable_etch file"
460
+ end
461
+ end
462
+ return true, message
463
+ end
464
+
465
+ def get_blank_request
466
+ @blankrequest.dup
467
+ end
468
+
469
+ # Raises an exception if any fatal error is encountered
470
+ # Returns a boolean, true unless the user indicated in interactive mode
471
+ # that further processing should be halted
472
+ def process_file(file, responsedata)
473
+ continue_processing = true
474
+ save_results = true
475
+ exception = nil
476
+
477
+ # We may not have configuration for this file, if it does not apply
478
+ # to this host. The server takes care of detecting any errors that
479
+ # might involve, so here we can just silently return.
480
+ config = responsedata[:configs][file]
481
+ if !config
482
+ puts "No configuration for #{file}, skipping" if (@debug)
483
+ return continue_processing
484
+ end
485
+
486
+ # Skip files we've already processed in response to <depend>
487
+ # statements.
488
+ if @already_processed.has_key?(file)
489
+ puts "Skipping already processed #{file}" if (@debug)
490
+ return continue_processing
491
+ end
492
+
493
+ # Prep the results capturing for this file
494
+ result = {}
495
+ result['file'] = file
496
+ result['success'] = true
497
+ result['message'] = ''
498
+
499
+ # catch/throw for expected/non-error events that end processing
500
+ # begin/raise for error events that end processing
501
+ # Within this block you should throw :process_done if you've reached
502
+ # a natural stopping point and nothing further needs to be done. You
503
+ # should raise an exception if you encounter an error condition.
504
+ # Do not 'return' or 'abort'.
505
+ catch :process_done do
506
+ begin
507
+ start_output_capture
508
+
509
+ puts "Processing #{file}" if (@debug)
510
+
511
+ # The %locked_files hash provides a convenient way to
512
+ # detect circular dependancies. It doesn't give us an ordered
513
+ # list of dependencies, which might be handy to help the user
514
+ # debug the problem, but I don't think it's worth maintaining a
515
+ # seperate array just for that purpose.
516
+ if @locked_files.has_key?(file)
517
+ raise "Circular dependancy detected. " +
518
+ "Dependancy list (unsorted) contains:\n " +
519
+ @locked_files.keys.join(', ')
520
+ end
521
+
522
+ # This needs to be after the circular dependency check
523
+ lock_file(file)
524
+
525
+ # Process any other files that this file depends on
526
+ config.elements.each('/config/depend') do |depend|
527
+ puts "Processing dependency #{depend.text}" if (@debug)
528
+ process_file(depend.text, responsedata)
529
+ end
530
+
531
+ # Process any commands that this file depends on
532
+ config.elements.each('/config/dependcommand') do |dependcommand|
533
+ puts "Processing command dependency #{dependcommand.text}" if (@debug)
534
+ process_commands(dependcommand.text, responsedata)
535
+ end
536
+
537
+ # See what type of action the user has requested
538
+
539
+ # Check to see if the user has requested that we revert back to the
540
+ # original file.
541
+ if config.elements['/config/revert']
542
+ origpathbase = File.join(@origbase, file)
543
+
544
+ # Restore the original file if it is around
545
+ if File.exist?("#{origpathbase}.ORIG")
546
+ origpath = "#{origpathbase}.ORIG"
547
+ origdir = File.dirname(origpath)
548
+ origbase = File.basename(origpath)
549
+ filedir = File.dirname(file)
550
+
551
+ # Remove anything we might have written out for this file
552
+ remove_file(file) if (!@dryrun)
553
+
554
+ puts "Restoring #{origpath} to #{file}"
555
+ recursive_copy_and_rename(origdir, origbase, file) if (!@dryrun)
556
+
557
+ # Now remove the backed-up original so that future runs
558
+ # don't do anything
559
+ remove_file(origpath) if (!@dryrun)
560
+ elsif File.exist?("#{origpathbase}.TAR")
561
+ origpath = "#{origpathbase}.TAR"
562
+ filedir = File.dirname(file)
563
+
564
+ # Remove anything we might have written out for this file
565
+ remove_file(file) if (!@dryrun)
566
+
567
+ puts "Restoring #{file} from #{origpath}"
568
+ system("cd #{filedir} && tar xf #{origpath}") if (!@dryrun)
569
+
570
+ # Now remove the backed-up original so that future runs
571
+ # don't do anything
572
+ remove_file(origpath) if (!@dryrun)
573
+ elsif File.exist?("#{origpathbase}.NOORIG")
574
+ origpath = "#{origpathbase}.NOORIG"
575
+ puts "Original #{file} didn't exist, restoring that state"
576
+
577
+ # Remove anything we might have written out for this file
578
+ remove_file(file) if (!@dryrun)
579
+
580
+ # Now remove the backed-up original so that future runs
581
+ # don't do anything
582
+ remove_file(origpath) if (!@dryrun)
583
+ end
584
+
585
+ throw :process_done
586
+ end
587
+
588
+ # Perform any setup commands that the user has requested.
589
+ # These are occasionally needed to install software that is
590
+ # required to generate the file (think m4 for sendmail.cf) or to
591
+ # install a package containing a sample config file which we
592
+ # then edit with a script, and thus doing the install in <pre>
593
+ # is too late.
594
+ if config.elements['/config/setup']
595
+ process_setup(file, config)
596
+ end
597
+
598
+ if config.elements['/config/file'] # Regular file
599
+ newcontents = nil
600
+ if config.elements['/config/file/contents']
601
+ newcontents = Base64.decode64(config.elements['/config/file/contents'].text)
602
+ end
603
+
604
+ permstring = config.elements['/config/file/perms'].text
605
+ perms = permstring.oct
606
+ owner = config.elements['/config/file/owner'].text
607
+ group = config.elements['/config/file/group'].text
608
+ uid = lookup_uid(owner)
609
+ gid = lookup_gid(group)
610
+
611
+ set_file_contents = false
612
+ if newcontents
613
+ set_file_contents = !compare_file_contents(file, newcontents)
614
+ end
615
+ set_permissions = nil
616
+ set_ownership = nil
617
+ # If the file is currently something other than a plain file then
618
+ # always set the flags to set the permissions and ownership.
619
+ # Checking the permissions/ownership of whatever is there currently
620
+ # is useless.
621
+ if set_file_contents && (!File.file?(file) || File.symlink?(file))
622
+ set_permissions = true
623
+ set_ownership = true
624
+ else
625
+ set_permissions = !compare_permissions(file, perms)
626
+ set_ownership = !compare_ownership(file, uid, gid)
627
+ end
628
+
629
+ # Proceed if:
630
+ # - The new contents are different from the current file
631
+ # - The permissions or ownership requested don't match the
632
+ # current permissions or ownership
633
+ if !set_file_contents &&
634
+ !set_permissions &&
635
+ !set_ownership
636
+ puts "No change to #{file} necessary" if (@debug)
637
+ throw :process_done
638
+ else
639
+ # Tell the user what we're going to do
640
+ if set_file_contents
641
+ # If the new contents are different from the current file
642
+ # show that to the user in the format they've requested.
643
+ # If the requested permissions are not world-readable then
644
+ # use the filenameonly format so that we don't disclose
645
+ # non-public data, unless we're in interactive mode
646
+ if @filenameonly || (permstring.to_i(8) & 0004 == 0 && !@interactive)
647
+ puts "Will write out new #{file}"
648
+ elsif @fullfile
649
+ # Grab the first 8k of the contents
650
+ first8k = newcontents.slice(0, 8192)
651
+ # Then check it for null characters. If it has any it's
652
+ # likely a binary file.
653
+ hasnulls = true if (first8k =~ /\0/)
654
+
655
+ if !hasnulls
656
+ puts "Generated contents for #{file}:"
657
+ puts "============================================="
658
+ puts newcontents
659
+ puts "============================================="
660
+ else
661
+ puts "Will write out new #{file}, but " +
662
+ "generated contents are not plain text so " +
663
+ "they will not be displayed"
664
+ end
665
+ else
666
+ # Default is to show a diff of the current file and the
667
+ # newly generated file.
668
+ puts "Will make the following changes to #{file}, diff -c:"
669
+ tempfile = Tempfile.new(File.basename(file))
670
+ tempfile.write(newcontents)
671
+ tempfile.close
672
+ puts "============================================="
673
+ if File.file?(file) && !File.symlink?(file)
674
+ system("diff -c #{file} #{tempfile.path}")
675
+ else
676
+ # Either the file doesn't currently exist,
677
+ # or is something other than a normal file
678
+ # that we'll be replacing with a file. In
679
+ # either case diffing against /dev/null will
680
+ # produce the most logical output.
681
+ system("diff -c /dev/null #{tempfile.path}")
682
+ end
683
+ puts "============================================="
684
+ tempfile.delete
685
+ end
686
+ end
687
+ if set_permissions
688
+ puts "Will set permissions on #{file} to #{permstring}"
689
+ end
690
+ if set_ownership
691
+ puts "Will set ownership of #{file} to #{uid}:#{gid}"
692
+ end
693
+
694
+ # If the user requested interactive mode ask them for
695
+ # confirmation to proceed.
696
+ if @interactive
697
+ case get_user_confirmation()
698
+ when CONFIRM_PROCEED
699
+ # No need to do anything
700
+ when CONFIRM_SKIP
701
+ save_results = false
702
+ throw :process_done
703
+ when CONFIRM_QUIT
704
+ unlock_all_files
705
+ continue_processing = false
706
+ save_results = false
707
+ throw :process_done
708
+ else
709
+ raise "Unexpected result from get_user_confirmation()"
710
+ end
711
+ end
712
+
713
+ # Perform any pre-action commands that the user has requested
714
+ if config.elements['/config/pre']
715
+ process_pre(file, config)
716
+ end
717
+
718
+ # If the original "file" is a directory and the user hasn't
719
+ # specifically told us we can overwrite it then raise an exception.
720
+ #
721
+ # The test is here, rather than a bit earlier where you might
722
+ # expect it, because the pre section may be used to address
723
+ # originals which are directories. So we don't check until
724
+ # after any pre commands are run.
725
+ if File.directory?(file) && !File.symlink?(file) &&
726
+ !config.elements['/config/file/overwrite_directory']
727
+ raise "Can't proceed, original of #{file} is a directory,\n" +
728
+ " consider the overwrite_directory flag if appropriate."
729
+ end
730
+
731
+ # Give save_orig a definitive answer on whether or not to save the
732
+ # contents of an original directory.
733
+ origpath = save_orig(file, true)
734
+ # Update the history log
735
+ save_history(file)
736
+
737
+ # Make sure the directory tree for this file exists
738
+ filedir = File.dirname(file)
739
+ if !File.directory?(filedir)
740
+ puts "Making directory tree #{filedir}"
741
+ FileUtils.mkpath(filedir) if (!@dryrun)
742
+ end
743
+
744
+ # Make a backup in case we need to roll back. We have no use
745
+ # for a backup if there are no test commands defined (since we
746
+ # only use the backup to roll back if the test fails), so don't
747
+ # bother to create a backup unless there is a test command defined.
748
+ backup = nil
749
+ if config.elements['/config/test_before_post'] ||
750
+ config.elements['/config/test']
751
+ backup = make_backup(file)
752
+ puts "Created backup #{backup}"
753
+ end
754
+
755
+ # If the new contents are different from the current file,
756
+ # replace the file.
757
+ if set_file_contents
758
+ if !@dryrun
759
+ # Write out the new contents into a temporary file
760
+ filebase = File.basename(file)
761
+ filedir = File.dirname(file)
762
+ newfile = Tempfile.new(filebase, filedir)
763
+
764
+ # Set the proper permissions on the file before putting
765
+ # data into it.
766
+ newfile.chmod(perms)
767
+ begin
768
+ newfile.chown(uid, gid)
769
+ rescue Errno::EPERM
770
+ raise if Process.euid == 0
771
+ end
772
+
773
+ puts "Writing new contents of #{file} to #{newfile.path}" if (@debug)
774
+ newfile.write(newcontents)
775
+ newfile.close
776
+
777
+ # If the current file is not a plain file, remove it.
778
+ # Plain files are left alone so that the replacement is
779
+ # atomic.
780
+ if File.symlink?(file) || (File.exist?(file) && ! File.file?(file))
781
+ puts "Current #{file} is not a plain file, removing it" if (@debug)
782
+ remove_file(file)
783
+ end
784
+
785
+ # Move the new file into place
786
+ File.rename(newfile.path, file)
787
+
788
+ # Check the permissions and ownership now to ensure they
789
+ # end up set properly
790
+ set_permissions = !compare_permissions(file, perms)
791
+ set_ownership = !compare_ownership(file, uid, gid)
792
+ end
793
+ end
794
+
795
+ # Ensure the permissions are set properly
796
+ if set_permissions
797
+ File.chmod(perms, file) if (!@dryrun)
798
+ end
799
+
800
+ # Ensure the ownership is set properly
801
+ if set_ownership
802
+ begin
803
+ File.chown(uid, gid, file) if (!@dryrun)
804
+ rescue Errno::EPERM
805
+ raise if Process.euid == 0
806
+ end
807
+ end
808
+
809
+ # Perform any test_before_post commands that the user has requested
810
+ if config.elements['/config/test_before_post']
811
+ if !process_test_before_post(file, config)
812
+ restore_backup(file, backup)
813
+ raise "test_before_post failed"
814
+ end
815
+ end
816
+
817
+ # Perform any post-action commands that the user has requested
818
+ if config.elements['/config/post']
819
+ process_post(file, config)
820
+ end
821
+
822
+ # Perform any test commands that the user has requested
823
+ if config.elements['/config/test']
824
+ if !process_test(file, config)
825
+ restore_backup(file, backup)
826
+
827
+ # Re-run any post commands
828
+ if config.elements['/config/post']
829
+ process_post(file, config)
830
+ end
831
+ end
832
+ end
833
+
834
+ # Clean up the backup, we don't need it anymore
835
+ if config.elements['/config/test_before_post'] ||
836
+ config.elements['/config/test']
837
+ puts "Removing backup #{backup}"
838
+ remove_file(backup) if (!@dryrun)
839
+ end
840
+
841
+ # Update the history log again
842
+ save_history(file)
843
+
844
+ throw :process_done
845
+ end
846
+ end
847
+
848
+ if config.elements['/config/link'] # Symbolic link
849
+
850
+ dest = config.elements['/config/link/dest'].text
851
+
852
+ set_link_destination = !compare_link_destination(file, dest)
853
+ absdest = File.expand_path(dest, File.dirname(file))
854
+
855
+ permstring = config.elements['/config/link/perms'].text
856
+ perms = permstring.oct
857
+ owner = config.elements['/config/link/owner'].text
858
+ group = config.elements['/config/link/group'].text
859
+ uid = lookup_uid(owner)
860
+ gid = lookup_gid(group)
861
+
862
+ # lchown and lchmod are not supported on many platforms. The server
863
+ # always includes ownership and permissions settings with any link
864
+ # (pulling them from defaults.xml if the user didn't specify them in
865
+ # the config.xml file.) As such link management would always fail
866
+ # on systems which don't support lchown/lchmod, which seems like bad
867
+ # behavior. So instead we check to see if they are implemented, and
868
+ # if not just ignore ownership/permissions settings. I suppose the
869
+ # ideal would be for the server to tell the client whether the
870
+ # ownership/permissions were specifically requested (in config.xml)
871
+ # rather than just defaults, and then for the client to always try to
872
+ # manage ownership/permissions if the settings are not defaults (and
873
+ # fail in the event that they aren't implemented.)
874
+ if @lchown_supported.nil?
875
+ lchowntestlink = Tempfile.new('etchlchowntest').path
876
+ lchowntestfile = Tempfile.new('etchlchowntest').path
877
+ File.delete(lchowntestlink)
878
+ File.symlink(lchowntestfile, lchowntestlink)
879
+ begin
880
+ File.lchown(0, 0, lchowntestfile)
881
+ @lchown_supported = true
882
+ rescue NotImplementedError
883
+ @lchown_supported = false
884
+ rescue Errno::EPERM
885
+ raise if Process.euid == 0
886
+ end
887
+ File.delete(lchowntestlink)
888
+ end
889
+ if @lchmod_supported.nil?
890
+ lchmodtestlink = Tempfile.new('etchlchmodtest').path
891
+ lchmodtestfile = Tempfile.new('etchlchmodtest').path
892
+ File.delete(lchmodtestlink)
893
+ File.symlink(lchmodtestfile, lchmodtestlink)
894
+ begin
895
+ File.lchmod(0644, lchmodtestfile)
896
+ @lchmod_supported = true
897
+ rescue NotImplementedError
898
+ @lchmod_supported = false
899
+ end
900
+ File.delete(lchmodtestlink)
901
+ end
902
+
903
+ set_permissions = false
904
+ if @lchmod_supported
905
+ # If the file is currently something other than a link then
906
+ # always set the flags to set the permissions and ownership.
907
+ # Checking the permissions/ownership of whatever is there currently
908
+ # is useless.
909
+ if set_link_destination && !File.symlink?(file)
910
+ set_permissions = true
911
+ else
912
+ set_permissions = !compare_permissions(file, perms)
913
+ end
914
+ end
915
+ set_ownership = false
916
+ if @lchown_supported
917
+ if set_link_destination && !File.symlink?(file)
918
+ set_ownership = true
919
+ else
920
+ set_ownership = !compare_ownership(file, uid, gid)
921
+ end
922
+ end
923
+
924
+ # Proceed if:
925
+ # - The new link destination differs from the current one
926
+ # - The permissions or ownership requested don't match the
927
+ # current permissions or ownership
928
+ if !set_link_destination &&
929
+ !set_permissions &&
930
+ !set_ownership
931
+ puts "No change to #{file} necessary" if (@debug)
932
+ throw :process_done
933
+ # Check that the link destination exists, and refuse to create
934
+ # the link unless it does exist or the user told us to go ahead
935
+ # anyway.
936
+ #
937
+ # Note that the destination may be a relative path, and the
938
+ # target directory may not exist yet, so we have to convert the
939
+ # destination to an absolute path and test that for existence.
940
+ # expand_path should handle paths that are already absolute
941
+ # properly.
942
+ elsif ! File.exist?(absdest) && ! File.symlink?(absdest) &&
943
+ ! config.elements['/config/link/allow_nonexistent_dest']
944
+ puts "Destination #{dest} for link #{file} does not exist," +
945
+ " consider the allow_nonexistent_dest flag if appropriate."
946
+ throw :process_done
947
+ else
948
+ # Tell the user what we're going to do
949
+ if set_link_destination
950
+ puts "Linking #{file} -> #{dest}"
951
+ end
952
+ if set_permissions
953
+ puts "Will set permissions on #{file} to #{permstring}"
954
+ end
955
+ if set_ownership
956
+ puts "Will set ownership of #{file} to #{uid}:#{gid}"
957
+ end
958
+
959
+ # If the user requested interactive mode ask them for
960
+ # confirmation to proceed.
961
+ if @interactive
962
+ case get_user_confirmation()
963
+ when CONFIRM_PROCEED
964
+ # No need to do anything
965
+ when CONFIRM_SKIP
966
+ save_results = false
967
+ throw :process_done
968
+ when CONFIRM_QUIT
969
+ unlock_all_files
970
+ continue_processing = false
971
+ save_results = false
972
+ throw :process_done
973
+ else
974
+ raise "Unexpected result from get_user_confirmation()"
975
+ end
976
+ end
977
+
978
+ # Perform any pre-action commands that the user has requested
979
+ if config.elements['/config/pre']
980
+ process_pre(file, config)
981
+ end
982
+
983
+ # If the original "file" is a directory and the user hasn't
984
+ # specifically told us we can overwrite it then raise an exception.
985
+ #
986
+ # The test is here, rather than a bit earlier where you might
987
+ # expect it, because the pre section may be used to address
988
+ # originals which are directories. So we don't check until
989
+ # after any pre commands are run.
990
+ if File.directory?(file) && !File.symlink?(file) &&
991
+ !config.elements['/config/link/overwrite_directory']
992
+ raise "Can't proceed, original of #{file} is a directory,\n" +
993
+ " consider the overwrite_directory flag if appropriate."
994
+ end
995
+
996
+ # Give save_orig a definitive answer on whether or not to save the
997
+ # contents of an original directory.
998
+ origpath = save_orig(file, true)
999
+ # Update the history log
1000
+ save_history(file)
1001
+
1002
+ # Make sure the directory tree for this link exists
1003
+ filedir = File.dirname(file)
1004
+ if !File.directory?(filedir)
1005
+ puts "Making directory tree #{filedir}"
1006
+ FileUtils.mkpath(filedir) if (!@dryrun)
1007
+ end
1008
+
1009
+ # Make a backup in case we need to roll back. We have no use
1010
+ # for a backup if there are no test commands defined (since we
1011
+ # only use the backup to roll back if the test fails), so don't
1012
+ # bother to create a backup unless there is a test command defined.
1013
+ backup = nil
1014
+ if config.elements['/config/test_before_post'] ||
1015
+ config.elements['/config/test']
1016
+ backup = make_backup(file)
1017
+ puts "Created backup #{backup}"
1018
+ end
1019
+
1020
+ # Create the link
1021
+ if set_link_destination
1022
+ remove_file(file) if (!@dryrun)
1023
+ File.symlink(dest, file) if (!@dryrun)
1024
+
1025
+ # Check the permissions and ownership now to ensure they
1026
+ # end up set properly
1027
+ if @lchmod_supported
1028
+ set_permissions = !compare_permissions(file, perms)
1029
+ end
1030
+ if @lchown_supported
1031
+ set_ownership = !compare_ownership(file, uid, gid)
1032
+ end
1033
+ end
1034
+
1035
+ # Ensure the permissions are set properly
1036
+ if set_permissions
1037
+ # Note: lchmod
1038
+ File.lchmod(perms, file) if (!@dryrun)
1039
+ end
1040
+
1041
+ # Ensure the ownership is set properly
1042
+ if set_ownership
1043
+ begin
1044
+ # Note: lchown
1045
+ File.lchown(uid, gid, file) if (!@dryrun)
1046
+ rescue Errno::EPERM
1047
+ raise if Process.euid == 0
1048
+ end
1049
+ end
1050
+
1051
+ # Perform any test_before_post commands that the user has requested
1052
+ if config.elements['/config/test_before_post']
1053
+ if !process_test_before_post(file, config)
1054
+ restore_backup(file, backup)
1055
+ raise "test_before_post failed"
1056
+ end
1057
+ end
1058
+
1059
+ # Perform any post-action commands that the user has requested
1060
+ if config.elements['/config/post']
1061
+ process_post(file, config)
1062
+ end
1063
+
1064
+ # Perform any test commands that the user has requested
1065
+ if config.elements['/config/test']
1066
+ if !process_test(file, config)
1067
+ restore_backup(file, backup)
1068
+
1069
+ # Re-run any post commands
1070
+ if config.elements['/config/post']
1071
+ process_post(file, config)
1072
+ end
1073
+ end
1074
+ end
1075
+
1076
+ # Clean up the backup, we don't need it anymore
1077
+ if config.elements['/config/test_before_post'] ||
1078
+ config.elements['/config/test']
1079
+ puts "Removing backup #{backup}"
1080
+ remove_file(backup) if (!@dryrun)
1081
+ end
1082
+
1083
+ # Update the history log again
1084
+ save_history(file)
1085
+
1086
+ throw :process_done
1087
+ end
1088
+ end
1089
+
1090
+ if config.elements['/config/directory'] # Directory
1091
+
1092
+ # A little safety check
1093
+ create = config.elements['/config/directory/create']
1094
+ raise "No create element found in directory section" if !create
1095
+
1096
+ permstring = config.elements['/config/directory/perms'].text
1097
+ perms = permstring.oct
1098
+ owner = config.elements['/config/directory/owner'].text
1099
+ group = config.elements['/config/directory/group'].text
1100
+ uid = lookup_uid(owner)
1101
+ gid = lookup_gid(group)
1102
+
1103
+ set_directory = !File.directory?(file) || File.symlink?(file)
1104
+ set_permissions = nil
1105
+ set_ownership = nil
1106
+ # If the file is currently something other than a directory then
1107
+ # always set the flags to set the permissions and ownership.
1108
+ # Checking the permissions/ownership of whatever is there currently
1109
+ # is useless.
1110
+ if set_directory
1111
+ set_permissions = true
1112
+ set_ownership = true
1113
+ else
1114
+ set_permissions = !compare_permissions(file, perms)
1115
+ set_ownership = !compare_ownership(file, uid, gid)
1116
+ end
1117
+
1118
+ # Proceed if:
1119
+ # - The current file is not a directory
1120
+ # - The permissions or ownership requested don't match the
1121
+ # current permissions or ownership
1122
+ if !set_directory &&
1123
+ !set_permissions &&
1124
+ !set_ownership
1125
+ puts "No change to #{file} necessary" if (@debug)
1126
+ throw :process_done
1127
+ else
1128
+ # Tell the user what we're going to do
1129
+ if set_directory
1130
+ puts "Making directory #{file}"
1131
+ end
1132
+ if set_permissions
1133
+ puts "Will set permissions on #{file} to #{permstring}"
1134
+ end
1135
+ if set_ownership
1136
+ puts "Will set ownership of #{file} to #{uid}:#{gid}"
1137
+ end
1138
+
1139
+ # If the user requested interactive mode ask them for
1140
+ # confirmation to proceed.
1141
+ if @interactive
1142
+ case get_user_confirmation()
1143
+ when CONFIRM_PROCEED
1144
+ # No need to do anything
1145
+ when CONFIRM_SKIP
1146
+ save_results = false
1147
+ throw :process_done
1148
+ when CONFIRM_QUIT
1149
+ unlock_all_files
1150
+ continue_processing = false
1151
+ save_results = false
1152
+ throw :process_done
1153
+ else
1154
+ raise "Unexpected result from get_user_confirmation()"
1155
+ end
1156
+ end
1157
+
1158
+ # Perform any pre-action commands that the user has requested
1159
+ if config.elements['/config/pre']
1160
+ process_pre(file, config)
1161
+ end
1162
+
1163
+ # Give save_orig a definitive answer on whether or not to save the
1164
+ # contents of an original directory.
1165
+ origpath = save_orig(file, false)
1166
+ # Update the history log
1167
+ save_history(file)
1168
+
1169
+ # Make sure the directory tree for this directory exists
1170
+ filedir = File.dirname(file)
1171
+ if !File.directory?(filedir)
1172
+ puts "Making directory tree #{filedir}"
1173
+ FileUtils.mkpath(filedir) if (!@dryrun)
1174
+ end
1175
+
1176
+ # Make a backup in case we need to roll back. We have no use
1177
+ # for a backup if there are no test commands defined (since we
1178
+ # only use the backup to roll back if the test fails), so don't
1179
+ # bother to create a backup unless there is a test command defined.
1180
+ backup = nil
1181
+ if config.elements['/config/test_before_post'] ||
1182
+ config.elements['/config/test']
1183
+ backup = make_backup(file)
1184
+ puts "Created backup #{backup}"
1185
+ end
1186
+
1187
+ # Create the directory
1188
+ if set_directory
1189
+ remove_file(file) if (!@dryrun)
1190
+ Dir.mkdir(file) if (!@dryrun)
1191
+
1192
+ # Check the permissions and ownership now to ensure they
1193
+ # end up set properly
1194
+ set_permissions = !compare_permissions(file, perms)
1195
+ set_ownership = !compare_ownership(file, uid, gid)
1196
+ end
1197
+
1198
+ # Ensure the permissions are set properly
1199
+ if set_permissions
1200
+ File.chmod(perms, file) if (!@dryrun)
1201
+ end
1202
+
1203
+ # Ensure the ownership is set properly
1204
+ if set_ownership
1205
+ begin
1206
+ File.chown(uid, gid, file) if (!@dryrun)
1207
+ rescue Errno::EPERM
1208
+ raise if Process.euid == 0
1209
+ end
1210
+ end
1211
+
1212
+ # Perform any test_before_post commands that the user has requested
1213
+ if config.elements['/config/test_before_post']
1214
+ if !process_test_before_post(file, config)
1215
+ restore_backup(file, backup)
1216
+ raise "test_before_post failed"
1217
+ end
1218
+ end
1219
+
1220
+ # Perform any post-action commands that the user has requested
1221
+ if config.elements['/config/post']
1222
+ process_post(file, config)
1223
+ end
1224
+
1225
+ # Perform any test commands that the user has requested
1226
+ if config.elements['/config/test']
1227
+ if !process_test(file, config)
1228
+ restore_backup(file, backup)
1229
+
1230
+ # Re-run any post commands
1231
+ if config.elements['/config/post']
1232
+ process_post(file, config)
1233
+ end
1234
+ end
1235
+ end
1236
+
1237
+ # Clean up the backup, we don't need it anymore
1238
+ if config.elements['/config/test_before_post'] ||
1239
+ config.elements['/config/test']
1240
+ puts "Removing backup #{backup}"
1241
+ remove_file(backup) if (!@dryrun)
1242
+ end
1243
+
1244
+ # Update the history log again
1245
+ save_history(file)
1246
+
1247
+ throw :process_done
1248
+ end
1249
+ end
1250
+
1251
+ if config.elements['/config/delete'] # Delete whatever is there
1252
+
1253
+ # A little safety check
1254
+ proceed = config.elements['/config/delete/proceed']
1255
+ raise "No proceed element found in delete section" if !proceed
1256
+
1257
+ # Proceed only if the file currently exists
1258
+ if !File.exist?(file) && !File.symlink?(file)
1259
+ throw :process_done
1260
+ else
1261
+ # Tell the user what we're going to do
1262
+ puts "Removing #{file}"
1263
+
1264
+ # If the user requested interactive mode ask them for
1265
+ # confirmation to proceed.
1266
+ if @interactive
1267
+ case get_user_confirmation()
1268
+ when CONFIRM_PROCEED
1269
+ # No need to do anything
1270
+ when CONFIRM_SKIP
1271
+ save_results = false
1272
+ throw :process_done
1273
+ when CONFIRM_QUIT
1274
+ unlock_all_files
1275
+ continue_processing = false
1276
+ save_results = false
1277
+ throw :process_done
1278
+ else
1279
+ raise "Unexpected result from get_user_confirmation()"
1280
+ end
1281
+ end
1282
+
1283
+ # Perform any pre-action commands that the user has requested
1284
+ if config.elements['/config/pre']
1285
+ process_pre(file, config)
1286
+ end
1287
+
1288
+ # If the original "file" is a directory and the user hasn't
1289
+ # specifically told us we can overwrite it then raise an exception.
1290
+ #
1291
+ # The test is here, rather than a bit earlier where you might
1292
+ # expect it, because the pre section may be used to address
1293
+ # originals which are directories. So we don't check until
1294
+ # after any pre commands are run.
1295
+ if File.directory?(file) && !File.symlink?(file) &&
1296
+ !config.elements['/config/delete/overwrite_directory']
1297
+ raise "Can't proceed, original of #{file} is a directory,\n" +
1298
+ " consider the overwrite_directory flag if appropriate."
1299
+ end
1300
+
1301
+ # Give save_orig a definitive answer on whether or not to save the
1302
+ # contents of an original directory.
1303
+ origpath = save_orig(file, true)
1304
+ # Update the history log
1305
+ save_history(file)
1306
+
1307
+ # Make a backup in case we need to roll back. We have no use
1308
+ # for a backup if there are no test commands defined (since we
1309
+ # only use the backup to roll back if the test fails), so don't
1310
+ # bother to create a backup unless there is a test command defined.
1311
+ backup = nil
1312
+ if config.elements['/config/test_before_post'] ||
1313
+ config.elements['/config/test']
1314
+ backup = make_backup(file)
1315
+ puts "Created backup #{backup}"
1316
+ end
1317
+
1318
+ # Remove the file
1319
+ remove_file(file) if (!@dryrun)
1320
+
1321
+ # Perform any test_before_post commands that the user has requested
1322
+ if config.elements['/config/test_before_post']
1323
+ if !process_test_before_post(file, config)
1324
+ restore_backup(file, backup)
1325
+ raise "test_before_post failed"
1326
+ end
1327
+ end
1328
+
1329
+ # Perform any post-action commands that the user has requested
1330
+ if config.elements['/config/post']
1331
+ process_post(file, config)
1332
+ end
1333
+
1334
+ # Perform any test commands that the user has requested
1335
+ if config.elements['/config/test']
1336
+ if !process_test(file, config)
1337
+ restore_backup(file, backup)
1338
+
1339
+ # Re-run any post commands
1340
+ if config.elements['/config/post']
1341
+ process_post(file, config)
1342
+ end
1343
+ end
1344
+ end
1345
+
1346
+ # Clean up the backup, we don't need it anymore
1347
+ if config.elements['/config/test_before_post'] ||
1348
+ config.elements['/config/test']
1349
+ puts "Removing backup #{backup}"
1350
+ remove_file(backup) if (!@dryrun)
1351
+ end
1352
+
1353
+ # Update the history log again
1354
+ save_history(file)
1355
+
1356
+ throw :process_done
1357
+ end
1358
+ end
1359
+ rescue Exception
1360
+ result['success'] = false
1361
+ exception = $!
1362
+ end # End begin block
1363
+ end # End :process_done catch block
1364
+
1365
+ unlock_file(file)
1366
+
1367
+ output = stop_output_capture
1368
+ if exception
1369
+ output << exception.message
1370
+ output << exception.backtrace.join("\n") if @debug
1371
+ end
1372
+ result['message'] << output
1373
+ if save_results
1374
+ @results << result
1375
+ end
1376
+
1377
+ if exception
1378
+ raise exception
1379
+ end
1380
+
1381
+ @already_processed[file] = true
1382
+
1383
+ continue_processing
1384
+ end
1385
+
1386
+ # Raises an exception if any fatal error is encountered
1387
+ # Returns a boolean, true unless the user indicated in interactive mode
1388
+ # that further processing should be halted
1389
+ def process_commands(commandname, responsedata)
1390
+ continue_processing = true
1391
+ save_results = true
1392
+ exception = nil
1393
+
1394
+ # We may not have configuration for this file, if it does not apply
1395
+ # to this host. The server takes care of detecting any errors that
1396
+ # might involve, so here we can just silently return.
1397
+ command = responsedata[:allcommands][commandname]
1398
+ if !command
1399
+ puts "No configuration for command #{commandname}, skipping" if (@debug)
1400
+ return continue_processing
1401
+ end
1402
+
1403
+ # Skip commands we've already processed in response to <depend>
1404
+ # statements.
1405
+ if @already_processed.has_key?(commandname)
1406
+ puts "Skipping already processed command #{commandname}" if (@debug)
1407
+ return continue_processing
1408
+ end
1409
+
1410
+ # Prep the results capturing for this command
1411
+ result = {}
1412
+ result['file'] = commandname
1413
+ result['success'] = true
1414
+ result['message'] = ''
1415
+
1416
+ # catch/throw for expected/non-error events that end processing
1417
+ # begin/raise for error events that end processing
1418
+ # Within this block you should throw :process_done if you've reached
1419
+ # a natural stopping point and nothing further needs to be done. You
1420
+ # should raise an exception if you encounter an error condition.
1421
+ # Do not 'return' or 'abort'.
1422
+ catch :process_done do
1423
+ begin
1424
+ start_output_capture
1425
+
1426
+ puts "Processing command #{commandname}" if (@debug)
1427
+
1428
+ # The %locked_files hash provides a convenient way to
1429
+ # detect circular dependancies. It doesn't give us an ordered
1430
+ # list of dependencies, which might be handy to help the user
1431
+ # debug the problem, but I don't think it's worth maintaining a
1432
+ # seperate array just for that purpose.
1433
+ if @locked_files.has_key?(commandname)
1434
+ raise "Circular command dependancy detected. " +
1435
+ "Dependancy list (unsorted) contains:\n " +
1436
+ @locked_files.keys.join(', ')
1437
+ end
1438
+
1439
+ # This needs to be after the circular dependency check
1440
+ lock_file(commandname)
1441
+
1442
+ # Process any other commands that this command depends on
1443
+ command.elements.each('/commands/depend') do |depend|
1444
+ puts "Processing command dependency #{depend.text}" if (@debug)
1445
+ process_commands(depend.text, responsedata)
1446
+ end
1447
+
1448
+ # Process any files that this command depends on
1449
+ command.elements.each('/commands/dependfile') do |dependfile|
1450
+ puts "Processing file dependency #{dependfile.text}" if (@debug)
1451
+ process_file(dependfile.text, responsedata)
1452
+ end
1453
+
1454
+ # Perform each step
1455
+ command.elements.each('/commands/step') do |step|
1456
+ guard = step.elements['guard/exec'].text
1457
+ command = step.elements['command/exec'].text
1458
+
1459
+ # Run guard, display only in debug (a la setup)
1460
+ guard_result = process_guard(guard, commandname)
1461
+
1462
+ if !guard_result
1463
+ # If the user requested interactive mode ask them for
1464
+ # confirmation to proceed.
1465
+ if @interactive
1466
+ case get_user_confirmation()
1467
+ when CONFIRM_PROCEED
1468
+ # No need to do anything
1469
+ when CONFIRM_SKIP
1470
+ save_results = false
1471
+ throw :process_done
1472
+ when CONFIRM_QUIT
1473
+ unlock_all_files
1474
+ continue_processing = false
1475
+ save_results = false
1476
+ throw :process_done
1477
+ else
1478
+ raise "Unexpected result from get_user_confirmation()"
1479
+ end
1480
+ end
1481
+
1482
+ # Run command, always display (a la pre/post)
1483
+ process_command(command, commandname)
1484
+
1485
+ # Re-run guard, always display, abort if fails
1486
+ guard_recheck_result = process_guard(guard, commandname)
1487
+ if !guard_recheck_result
1488
+ raise "Guard #{guard} still fails for #{commandname} after running command #{command}"
1489
+ end
1490
+ end
1491
+ end
1492
+ rescue Exception
1493
+ result['success'] = false
1494
+ exception = $!
1495
+ end # End begin block
1496
+ end # End :process_done catch block
1497
+
1498
+ unlock_file(commandname)
1499
+
1500
+ output = stop_output_capture
1501
+ if exception
1502
+ output << exception.message
1503
+ output << exception.backtrace.join("\n") if @debug
1504
+ end
1505
+ result['message'] << output
1506
+ if save_results
1507
+ @results << result
1508
+ end
1509
+
1510
+ if exception
1511
+ raise exception
1512
+ end
1513
+
1514
+ @already_processed[commandname] = true
1515
+
1516
+ continue_processing
1517
+ end
1518
+
1519
+ # Returns true if the new contents are the same as the current file,
1520
+ # false if the contents differ or if the file does not currently exist.
1521
+ def compare_file_contents(file, newcontents)
1522
+ # If the file currently exists and is a regular file then check to see
1523
+ # if the new contents are the same.
1524
+ if File.file?(file) && !File.symlink?(file)
1525
+ contents = IO.read(file)
1526
+ if newcontents == contents
1527
+ return true
1528
+ end
1529
+ end
1530
+ false
1531
+ end
1532
+
1533
+ # Returns true if the given file is a symlink which points to the given
1534
+ # destination, false if the link destination is different or if the file is
1535
+ # not a link or does not currently exist.
1536
+ def compare_link_destination(file, newdest)
1537
+ # If the file currently exists and is a link, check to see if the
1538
+ # new destination is different.
1539
+ if File.symlink?(file)
1540
+ currentdest = File.readlink(file)
1541
+ if currentdest == newdest
1542
+ return true
1543
+ end
1544
+ end
1545
+ false
1546
+ end
1547
+
1548
+ def get_orig_sum(file)
1549
+ Digest::SHA1.hexdigest(get_orig_contents(file))
1550
+ end
1551
+ def get_orig_contents(file)
1552
+ origpath = save_orig(file)
1553
+ orig_contents = nil
1554
+ # We only send back the actual original file contents if the original is
1555
+ # a regular file, otherwise we send back an empty string.
1556
+ if (origpath =~ /\.ORIG$/ || origpath =~ /\.TMP$/) &&
1557
+ File.file?(origpath) && !File.symlink?(origpath)
1558
+ orig_contents = IO.read(origpath)
1559
+ else
1560
+ orig_contents = ''
1561
+ end
1562
+ orig_contents
1563
+ end
1564
+ # Save an original copy of the file if that hasn't been done already.
1565
+ # save_directory_contents can take three different values:
1566
+ # true: If the original is a directory then the contents should be
1567
+ # saved by putting them into a tarball
1568
+ # false: If the original is a directory do not save the contents,
1569
+ # just save the metadata of that directory (ownership and perms)
1570
+ # nil: We haven't yet received a full configuration for the file,
1571
+ # just a request for the original file checksum or contents.
1572
+ # As such we don't know yet what to do with a directory's
1573
+ # contents, nor do we want to save the final version of
1574
+ # non-directories as future setup commands or activity
1575
+ # outside of etch might create or change the original file
1576
+ # before etch is configured to change it. I.e. we save an
1577
+ # original file the first time etch changes that particular
1578
+ # file, not the first time etch runs on the box.
1579
+ # Return the path to that original copy.
1580
+ def save_orig(file, save_directory_contents=nil)
1581
+ origpathbase = File.join(@origbase, file)
1582
+ origpath = nil
1583
+ tmporigpath = "#{origpathbase}.TMP"
1584
+
1585
+ if File.exist?("#{origpathbase}.ORIG") ||
1586
+ File.symlink?("#{origpathbase}.ORIG")
1587
+ origpath = "#{origpathbase}.ORIG"
1588
+ elsif File.exist?("#{origpathbase}.NOORIG")
1589
+ origpath = "#{origpathbase}.NOORIG"
1590
+ elsif File.exist?("#{origpathbase}.TAR")
1591
+ origpath = "#{origpathbase}.TAR"
1592
+ else
1593
+ # The original file has not yet been saved
1594
+ first_update = true
1595
+
1596
+ # Make sure the directory tree for this file exists in the
1597
+ # directory we save originals in.
1598
+ origdir = File.dirname(origpathbase)
1599
+ if !File.directory?(origdir)
1600
+ puts "Making directory tree #{origdir}"
1601
+ FileUtils.mkpath(origdir) if (!@dryrun)
1602
+ end
1603
+
1604
+ if File.directory?(file) && !File.symlink?(file)
1605
+ # The original "file" is a directory
1606
+ if save_directory_contents
1607
+ # Tar up the original directory
1608
+ origpath = "#{origpathbase}.TAR"
1609
+ filedir = File.dirname(file)
1610
+ filebase = File.basename(file)
1611
+ puts "Saving contents of original directory #{file} as #{origpath}"
1612
+ system("cd #{filedir} && tar cf #{origpath} #{filebase}") if (!@dryrun)
1613
+ # There may be contents in that directory that the
1614
+ # user doesn't want exposed. Without a way to know,
1615
+ # the safest thing is to set restrictive permissions
1616
+ # on the tar file.
1617
+ File.chmod(0400, origpath) if (!@dryrun)
1618
+ elsif save_directory_contents.nil?
1619
+ # We have a timing issue, in that we generally save original
1620
+ # files before we have the configuration for that file. For
1621
+ # directories that's a problem, because we save directories
1622
+ # differently depending on whether we're configuring them to
1623
+ # remain a directory, or replacing the directory with something
1624
+ # else (file or symlink). So if we don't have a definitive
1625
+ # directive on how to save the directory
1626
+ # (i.e. save_directory_contents is nil) then just save a
1627
+ # placeholder until we do get a definitive directive.
1628
+ origpath = tmporigpath
1629
+ if !File.directory?(tmporigpath) || File.symlink?(tmporigpath)
1630
+ puts "Creating temporary original placeholder #{tmporigpath} for directory #{file}"
1631
+ remove_file(tmporigpath) if (!@dryrun)
1632
+ Dir.mkdir(tmporigpath) if (!@dryrun)
1633
+ end
1634
+ first_update = false
1635
+ else
1636
+ # Just create a directory in the originals repository with
1637
+ # ownership and permissions to match the original directory.
1638
+ origpath = "#{origpathbase}.ORIG"
1639
+ st = File::Stat.new(file)
1640
+ puts "Saving ownership/permissions of original directory #{file} as #{origpath}"
1641
+ Dir.mkdir(origpath, st.mode) if (!@dryrun)
1642
+ begin
1643
+ File.chown(st.uid, st.gid, origpath) if (!@dryrun)
1644
+ rescue Errno::EPERM
1645
+ raise if Process.euid == 0
1646
+ end
1647
+ end
1648
+ elsif File.exist?(file) || File.symlink?(file)
1649
+ # The original file exists, and is not a directory
1650
+ proceed = true
1651
+ if save_directory_contents.nil?
1652
+ origpath = tmporigpath
1653
+ if File.exist?(tmporigpath) && !File.symlink?(tmporigpath) && compare_file_contents(tmporigpath, File.read(file))
1654
+ proceed = false
1655
+ else
1656
+ puts "Saving temporary copy of original file: #{file} -> #{origpath}"
1657
+ end
1658
+ else
1659
+ origpath = "#{origpathbase}.ORIG"
1660
+ puts "Saving original file: #{file} -> #{origpath}"
1661
+ end
1662
+ if proceed
1663
+ filedir = File.dirname(file)
1664
+ filebase = File.basename(file)
1665
+ recursive_copy_and_rename(filedir, filebase, origpath) if (!@dryrun)
1666
+ end
1667
+ else
1668
+ # If the original doesn't exist, we need to flag that so
1669
+ # that we don't try to save our generated file as an
1670
+ # original on future runs
1671
+ proceed = true
1672
+ if save_directory_contents.nil?
1673
+ origpath = tmporigpath
1674
+ if File.exist?(tmporigpath) && !File.symlink?(tmporigpath) && File.stat(tmporigpath).zero?
1675
+ proceed = false
1676
+ else
1677
+ puts "Original file #{file} doesn't exist, saving that state temporarily as #{tmporigpath}"
1678
+ end
1679
+ else
1680
+ origpath = "#{origpathbase}.NOORIG"
1681
+ puts "Original file #{file} doesn't exist, saving that state permanently as #{origpath}"
1682
+ end
1683
+ if proceed
1684
+ File.open(origpath, 'w') { |file| } if (!@dryrun)
1685
+ end
1686
+ end
1687
+
1688
+ @first_update[file] = first_update
1689
+ end
1690
+
1691
+ # Remove the TMP placeholder if it exists and no longer applies
1692
+ if origpath != tmporigpath && File.exists?(tmporigpath)
1693
+ puts "Removing old temp orig placeholder #{tmporigpath}" if (@debug)
1694
+ remove_file(tmporigpath)
1695
+ end
1696
+
1697
+ origpath
1698
+ end
1699
+
1700
+ # This subroutine maintains a revision history for the file in @historybase
1701
+ def save_history(file)
1702
+ histpath = File.join(@historybase, "#{file}.HISTORY")
1703
+
1704
+ # Make sure the directory tree for this file exists in the
1705
+ # directory we save history in.
1706
+ histdir = File.dirname(histpath)
1707
+ if !File.directory?(histdir)
1708
+ puts "Making directory tree #{histdir}"
1709
+ FileUtils.mkpath(histdir) if (!@dryrun)
1710
+ end
1711
+ # Make sure the corresponding RCS directory exists as well.
1712
+ histrcsdir = File.join(histdir, 'RCS')
1713
+ if !File.directory?(histrcsdir)
1714
+ puts "Making directory tree #{histrcsdir}"
1715
+ FileUtils.mkpath(histrcsdir) if (!@dryrun)
1716
+ end
1717
+
1718
+ # If the history log doesn't exist and we didn't just create the
1719
+ # original backup, that indicates that the original backup was made
1720
+ # previously but the history log was not started at the same time.
1721
+ # There are a variety of reasons why this might be the case (the
1722
+ # original was saved by a previous version of etch that didn't have
1723
+ # the history log feature, or the original was saved manually by
1724
+ # someone) but whatever the reason is we want to use the original
1725
+ # backup to start the history log before updating the history log
1726
+ # with the current file.
1727
+ if !File.exist?(histpath) && !@first_update[file]
1728
+ origpath = save_orig(file)
1729
+ if File.file?(origpath) && !File.symlink?(origpath)
1730
+ puts "Starting history log with saved original file: " +
1731
+ "#{origpath} -> #{histpath}"
1732
+ FileUtils.copy(origpath, histpath) if (!@dryrun)
1733
+ else
1734
+ puts "Starting history log with 'ls -ld' output for " +
1735
+ "saved original file: #{origpath} -> #{histpath}"
1736
+ system("ls -ld #{origpath} > #{histpath} 2>&1") if (!@dryrun)
1737
+ end
1738
+ # Check the newly created history file into RCS
1739
+ histbase = File.basename(histpath)
1740
+ puts "Checking initial history log into RCS: #{histpath}"
1741
+ if !@dryrun
1742
+ # The -m flag shouldn't be needed, but it won't hurt
1743
+ # anything and if something is out of sync and an RCS file
1744
+ # already exists it will prevent ci from going interactive.
1745
+ system(
1746
+ "cd #{histdir} && " +
1747
+ "ci -q -t-'Original of an etch modified file' " +
1748
+ "-m'Update of an etch modified file' #{histbase} && " +
1749
+ "co -q -r -kb #{histbase}")
1750
+ end
1751
+ set_history_permissions(file)
1752
+ end
1753
+
1754
+ # Copy current file
1755
+
1756
+ # If the file already exists in RCS we need to check out a locked
1757
+ # copy before updating it
1758
+ histbase = File.basename(histpath)
1759
+ rcsstatus = false
1760
+ if !@dryrun
1761
+ rcsstatus = system("cd #{histdir} && rlog -R #{histbase} > /dev/null 2>&1")
1762
+ end
1763
+ if rcsstatus
1764
+ # set_history_permissions may set the checked-out file
1765
+ # writeable, which normally causes co to abort. Thus the -f
1766
+ # flag.
1767
+ system("cd #{histdir} && co -q -l -f #{histbase}") if !@dryrun
1768
+ end
1769
+
1770
+ if File.file?(file) && !File.symlink?(file)
1771
+ puts "Updating history log: #{file} -> #{histpath}"
1772
+ FileUtils.copy(file, histpath) if (!@dryrun)
1773
+ else
1774
+ puts "Updating history log with 'ls -ld' output: " +
1775
+ "#{histpath}"
1776
+ system("ls -ld #{file} > #{histpath} 2>&1") if (!@dryrun)
1777
+ end
1778
+
1779
+ # Check the history file into RCS
1780
+ puts "Checking history log update into RCS: #{histpath}"
1781
+ if !@dryrun
1782
+ # We only need one of the -t or -m flags depending on whether
1783
+ # the history log already exists or not, rather than try to
1784
+ # keep track of which one we need just specify both and let RCS
1785
+ # pick the one it needs.
1786
+ system(
1787
+ "cd #{histdir} && " +
1788
+ "ci -q -t-'Original of an etch modified file' " +
1789
+ "-m'Update of an etch modified file' #{histbase} && " +
1790
+ "co -q -r -kb #{histbase}")
1791
+ end
1792
+
1793
+ set_history_permissions(file)
1794
+ end
1795
+
1796
+ # Ensures that the history log file has appropriate permissions to avoid
1797
+ # leaking information.
1798
+ def set_history_permissions(file)
1799
+ origpath = File.join(@origbase, "#{file}.ORIG")
1800
+ histpath = File.join(@historybase, "#{file}.HISTORY")
1801
+
1802
+ # We set the permissions to the more restrictive of the original
1803
+ # file permissions and the current file permissions.
1804
+ origperms = 0777
1805
+ if File.exist?(origpath)
1806
+ st = File.lstat(origpath)
1807
+ # Mask off the file type
1808
+ origperms = st.mode & 07777
1809
+ end
1810
+ fileperms = 0777
1811
+ if File.exist?(file)
1812
+ st = File.lstat(file)
1813
+ # Mask off the file type
1814
+ fileperms = st.mode & 07777
1815
+ end
1816
+
1817
+ histperms = origperms & fileperms
1818
+
1819
+ File.chmod(histperms, histpath) if (!@dryrun)
1820
+
1821
+ # Set the permissions on the RCS file too
1822
+ histbase = File.basename(histpath)
1823
+ histdir = File.dirname(histpath)
1824
+ histrcsdir = "#{histdir}/RCS"
1825
+ histrcspath = "#{histrcsdir}/#{histbase},v"
1826
+ File.chmod(histperms, histrcspath) if (!@dryrun)
1827
+ end
1828
+
1829
+ def get_local_requests(file)
1830
+ requestdir = File.join(@requestbase, file)
1831
+ requestlist = []
1832
+ if File.directory?(requestdir)
1833
+ Dir.foreach(requestdir) do |entry|
1834
+ next if entry == '.'
1835
+ next if entry == '..'
1836
+ requestfile = File.join(requestdir, entry)
1837
+ request = IO.read(requestfile)
1838
+ # Make sure it is valid XML
1839
+ begin
1840
+ request_xml = REXML::Document.new(request)
1841
+ rescue REXML::ParseException => e
1842
+ warn "Local request file #{requestfile} is not valid XML and will be ignored:\n" + e.message
1843
+ next
1844
+ end
1845
+ # Make sure the root element is <request>
1846
+ if request_xml.root.name != 'request'
1847
+ warn "Local request file #{requestfile} is not properly formatted and will be ignored, XML root element is not <request>"
1848
+ next
1849
+ end
1850
+ # Add it to the queue
1851
+ requestlist << request
1852
+ end
1853
+ end
1854
+ requests = nil
1855
+ if !requestlist.empty?
1856
+ requests = "<requests>\n#{requestlist.join('')}\n</requests>"
1857
+ end
1858
+ requests
1859
+ end
1860
+
1861
+ # Haven't found a Ruby method for creating temporary directories,
1862
+ # so create a temporary file and replace it with a directory.
1863
+ def tempdir(file)
1864
+ filebase = File.basename(file)
1865
+ filedir = File.dirname(file)
1866
+ tmpfile = Tempfile.new(filebase, filedir)
1867
+ tmpdir = tmpfile.path
1868
+ tmpfile.close!
1869
+ Dir.mkdir(tmpdir)
1870
+ tmpdir
1871
+ end
1872
+
1873
+ def make_backup(file)
1874
+ backup = nil
1875
+ filebase = File.basename(file)
1876
+ filedir = File.dirname(file)
1877
+ if !@dryrun
1878
+ backup = tempdir(file)
1879
+ else
1880
+ # Use a fake placeholder name for use in dry run/debug messages
1881
+ backup = "#{file}.XXXX"
1882
+ end
1883
+
1884
+ backuppath = File.join(backup, filebase)
1885
+
1886
+ puts "Making backup: #{file} -> #{backuppath}"
1887
+ if !@dryrun
1888
+ if File.exist?(file) || File.symlink?(file)
1889
+ recursive_copy(filedir, filebase, backup)
1890
+ else
1891
+ # If there's no file to back up then leave a marker file so
1892
+ # that restore_backup does the right thing
1893
+ File.open("#{backuppath}.NOORIG", "w") { |file| }
1894
+ end
1895
+ end
1896
+
1897
+ backup
1898
+ end
1899
+
1900
+ def restore_backup(file, backup)
1901
+ filebase = File.basename(file)
1902
+ backuppath = File.join(backup, filebase)
1903
+
1904
+ puts "Restoring #{backuppath} to #{file}"
1905
+ if !@dryrun
1906
+ # Clean up whatever we wrote out that caused the test to fail
1907
+ remove_file(file)
1908
+
1909
+ # Then restore the backup
1910
+ if File.exist?(backuppath) || File.symlink?(backuppath)
1911
+ File.rename(backuppath, file)
1912
+ remove_file(backup)
1913
+ elsif File.exist?("#{backuppath}.NOORIG")
1914
+ # There was no original file, so we don't need to do
1915
+ # anything except remove our NOORIG marker file
1916
+ remove_file(backup)
1917
+ else
1918
+ raise "No backup found in #{backup} to restore to #{file}"
1919
+ end
1920
+ end
1921
+ end
1922
+
1923
+ def process_setup(file, config)
1924
+ exectype = 'setup'
1925
+ # Because the setup commands are processed every time etch runs
1926
+ # (rather than just when the file has changed, as with pre/post) we
1927
+ # don't want to print a message for them unless we're in debug mode.
1928
+ puts "Processing #{exectype} commands" if (@debug)
1929
+ config.elements.each("/config/#{exectype}/exec") do |setup|
1930
+ r = process_exec(exectype, setup.text, file)
1931
+ # process_exec currently raises an exception if a setup or pre command
1932
+ # fails. In case that ever changes make sure we propagate
1933
+ # the error.
1934
+ return r if (!r)
1935
+ end
1936
+ end
1937
+ def process_pre(file, config)
1938
+ exectype = 'pre'
1939
+ puts "Processing #{exectype} commands"
1940
+ config.elements.each("/config/#{exectype}/exec") do |pre|
1941
+ r = process_exec(exectype, pre.text, file)
1942
+ # process_exec currently raises an exception if a setup or pre command
1943
+ # fails. In case that ever changes make sure we propagate
1944
+ # the error.
1945
+ return r if (!r)
1946
+ end
1947
+ end
1948
+ def process_post(file, config)
1949
+ exectype = 'post'
1950
+ execs = []
1951
+ puts "Processing #{exectype} commands"
1952
+
1953
+ # Add the "exec once" items into the list of commands to process
1954
+ # if this is the first time etch has updated this file, and if
1955
+ # we haven't already run the command.
1956
+ if @first_update[file]
1957
+ config.elements.each("/config/#{exectype}/exec_once") do |exec_once|
1958
+ if !@exec_already_processed.has_key?(exec_once.text)
1959
+ execs << exec_once.text
1960
+ @exec_already_processed[exec_once] = true
1961
+ else
1962
+ puts "Skipping '#{exec_once.text}', it has already " +
1963
+ "been executed once this run" if (@debug)
1964
+ end
1965
+ end
1966
+ end
1967
+
1968
+ # Add in the regular exec items as well
1969
+ config.elements.each("/config/#{exectype}/exec") do |exec|
1970
+ execs << exec.text
1971
+ end
1972
+
1973
+ # post failures are considered non-fatal, so we ignore the
1974
+ # return value from process_exec (it takes care of warning
1975
+ # the user).
1976
+ execs.each { |exec| process_exec(exectype, exec, file) }
1977
+
1978
+ config.elements.each("/config/#{exectype}/exec_once_per_run") do |eopr|
1979
+ # Stuff the "exec once per run" nodes into the global hash to
1980
+ # be run after we've processed all files.
1981
+ puts "Adding '#{eopr.text}' to 'exec once per run' list" if (@debug)
1982
+ @exec_once_per_run[eopr.text] = true
1983
+ end
1984
+ end
1985
+ def process_test_before_post(file, config)
1986
+ exectype = 'test_before_post'
1987
+ puts "Processing #{exectype} commands"
1988
+ config.elements.each("/config/#{exectype}/exec") do |test_before_post|
1989
+ r = process_exec(exectype, test_before_post.text, file)
1990
+ # If the test failed we need to propagate that error
1991
+ return r if (!r)
1992
+ end
1993
+ end
1994
+ def process_test(file, config)
1995
+ exectype = 'test'
1996
+ puts "Processing #{exectype} commands"
1997
+ config.elements.each("/config/#{exectype}/exec") do |test|
1998
+ r = process_exec(exectype, test.text, file)
1999
+ # If the test failed we need to propagate that error
2000
+ return r if (!r)
2001
+ end
2002
+ end
2003
+ def process_guard(guard, commandname)
2004
+ exectype = 'guard'
2005
+ # Because the guard commands are processed every time etch runs we don't
2006
+ # want to print a message for them unless we're in debug mode.
2007
+ puts "Processing #{exectype}" if (@debug)
2008
+ process_exec(exectype, guard, commandname)
2009
+ end
2010
+ def process_command(command, commandname)
2011
+ exectype = 'command'
2012
+ puts "Processing #{exectype}"
2013
+ process_exec(exectype, command, commandname)
2014
+ end
2015
+
2016
+ def process_exec(exectype, exec, file='')
2017
+ r = true
2018
+
2019
+ # Because the setup and guard commands are processed every time (rather
2020
+ # than just when the file has changed as with pre/post) we don't want to
2021
+ # print a message for them.
2022
+ puts " Executing '#{exec}'" if ((exectype != 'setup' && exectype != 'guard') || @debug)
2023
+
2024
+ # Actually run the command unless we're in a dry run, or if we're in
2025
+ # a damp run and the command is a setup command.
2026
+ if ! @dryrun || (@dryrun == 'damp' && exectype == 'setup')
2027
+ etch_priority = nil
2028
+
2029
+ if exectype == 'post' || exectype == 'command'
2030
+ # Etch is likely running at a lower priority than normal.
2031
+ # However, we don't want to run post commands at that
2032
+ # priority. If they restart processes (for example,
2033
+ # restarting sshd) the restarted process will be left
2034
+ # running at that same lower priority. sshd is particularly
2035
+ # nefarious, because further commands started by users via
2036
+ # that low priority sshd will also run at low priority.
2037
+ etch_priority = Process.getpriority(Process::PRIO_PROCESS, 0)
2038
+ if etch_priority != 0
2039
+ puts " Etch is running at priority #{etch_priority}, " +
2040
+ "temporarily adjusting priority to 0 to run #{exectype} command" if (@debug)
2041
+ Process.setpriority(Process::PRIO_PROCESS, 0, 0)
2042
+ end
2043
+ end
2044
+
2045
+ # Explicitly invoke using /bin/sh so that syntax like
2046
+ # "FOO=bar myprogram" works.
2047
+ r = system('/bin/sh', '-c', exec)
2048
+
2049
+ if exectype == 'post' || exectype == 'command'
2050
+ if etch_priority != 0
2051
+ puts " Returning priority to #{etch_priority}" if (@debug)
2052
+ Process.setpriority(Process::PRIO_USER, 0, etch_priority)
2053
+ end
2054
+ end
2055
+ end
2056
+
2057
+ # If the command exited with error
2058
+ if !r
2059
+ # We don't normally print the command we're executing for setup and
2060
+ # guard commands (see above). But that makes it hard to figure out
2061
+ # what's going on if it fails. So include the command in the message if
2062
+ # there was a failure.
2063
+ execmsg = ''
2064
+ execmsg = "'#{exec}' " if (exectype == 'setup' || exectype == 'guard')
2065
+
2066
+ # Normally we include the filename of the file that this command
2067
+ # is associated with in the messages we print. But for "exec once
2068
+ # per run" commands that doesn't apply. Assemble a variable
2069
+ # that has the filename if we have it, to be included in the
2070
+ # error message we're going to print.
2071
+ filemsg = ''
2072
+ filemsg = "for #{file} " if (!file.empty?)
2073
+
2074
+ # Setup and pre commands are almost always used to install
2075
+ # software prerequisites, and bad things generally happen if
2076
+ # those software installs fail. So consider it a fatal error if
2077
+ # that occurs.
2078
+ if exectype == 'setup' || exectype == 'pre'
2079
+ raise " Setup/Pre command " + execmsg + filemsg +
2080
+ "exited with non-zero value"
2081
+ # Post commands are generally used to restart services. While
2082
+ # it is unfortunate if they fail, there is little to be gained
2083
+ # by having etch exit if they do so. So simply warn if a post
2084
+ # command fails.
2085
+ elsif exectype == 'post'
2086
+ puts " Post command " + execmsg + filemsg +
2087
+ "exited with non-zero value"
2088
+ # process_commands takes the appropriate action when guards and commands
2089
+ # fail, so we just warn of any failures here.
2090
+ elsif exectype == 'guard'
2091
+ puts " Guard " + execmsg + filemsg + "exited with non-zero value"
2092
+ elsif exectype == 'command'
2093
+ puts " Command " + execmsg + filemsg + "exited with non-zero value"
2094
+ # For test commands we need to warn the user and then return a
2095
+ # value indicating the failure so that a rollback can be
2096
+ # performed.
2097
+ elsif exectype =~ /^test/
2098
+ puts " Test command " + execmsg + filemsg +
2099
+ "exited with non-zero value"
2100
+ end
2101
+ end
2102
+
2103
+ r
2104
+ end
2105
+
2106
+ def lookup_uid(user)
2107
+ uid = nil
2108
+ if user =~ /^\d+$/
2109
+ # If the user was specified as a numeric UID, use it directly.
2110
+ uid = user
2111
+ else
2112
+ # Otherwise attempt to look up the username to get a UID.
2113
+ # Default to UID 0 if the username can't be found.
2114
+ begin
2115
+ pw = Etc.getpwnam(user)
2116
+ uid = pw.uid
2117
+ rescue ArgumentError
2118
+ puts "config.xml requests user #{user}, but that user can't be found. Using UID 0."
2119
+ uid = 0
2120
+ end
2121
+ end
2122
+
2123
+ uid.to_i
2124
+ end
2125
+
2126
+ def lookup_gid(group)
2127
+ gid = nil
2128
+ if group =~ /^\d+$/
2129
+ # If the group was specified as a numeric GID, use it directly.
2130
+ gid = group
2131
+ else
2132
+ # Otherwise attempt to look up the group to get a GID. Default
2133
+ # to GID 0 if the group can't be found.
2134
+ begin
2135
+ gr = Etc.getgrnam(group)
2136
+ gid = gr.gid
2137
+ rescue ArgumentError
2138
+ puts "config.xml requests group #{group}, but that group can't be found. Using GID 0."
2139
+ gid = 0
2140
+ end
2141
+ end
2142
+
2143
+ gid.to_i
2144
+ end
2145
+
2146
+ # Returns true if the permissions of the given file match the given
2147
+ # permissions, false otherwise.
2148
+ def compare_permissions(file, perms)
2149
+ if File.exist?(file)
2150
+ st = File.lstat(file)
2151
+ # Mask off the file type
2152
+ fileperms = st.mode & 07777
2153
+ if perms == fileperms
2154
+ return true
2155
+ end
2156
+ end
2157
+ false
2158
+ end
2159
+
2160
+ # Returns true if the ownership of the given file match the given UID
2161
+ # and GID, false otherwise.
2162
+ def compare_ownership(file, uid, gid)
2163
+ if File.exist?(file)
2164
+ st = File.lstat(file)
2165
+ if st.uid == uid && st.gid == gid
2166
+ return true
2167
+ end
2168
+ end
2169
+ false
2170
+ end
2171
+
2172
+ def get_user_confirmation
2173
+ while true
2174
+ print "Proceed/Skip/Quit? [p|s|q] "
2175
+ response = $stdin.gets.chomp
2176
+ if response == 'p'
2177
+ return CONFIRM_PROCEED
2178
+ elsif response == 's'
2179
+ return CONFIRM_SKIP
2180
+ elsif response == 'q'
2181
+ return CONFIRM_QUIT
2182
+ end
2183
+ end
2184
+ end
2185
+
2186
+ def remove_file(file)
2187
+ if ! File.exist?(file) && ! File.symlink?(file)
2188
+ puts "remove_file: #{file} doesn't exist" if (@debug)
2189
+ else
2190
+ # The secure delete mechanism doesn't seem to work consistently
2191
+ # when not root (in the ever-so-helpful way of not actually
2192
+ # removing the file and not indicating any error)
2193
+ if Process.euid == 0
2194
+ FileUtils.rmtree(file, :secure => true)
2195
+ else
2196
+ FileUtils.rmtree(file)
2197
+ end
2198
+ end
2199
+ end
2200
+
2201
+ def recursive_copy(sourcedir, sourcefile, destdir)
2202
+ # Note that cp -p will follow symlinks. GNU cp has a -d option to
2203
+ # prevent that, but Solaris cp does not, so we resort to cpio.
2204
+ # GNU cpio has a --quiet option, but Solaris cpio does not. Sigh.
2205
+ system("cd #{sourcedir} && find #{sourcefile} | cpio -pdum #{destdir}") or
2206
+ raise "Copy #{sourcedir}/#{sourcefile} to #{destdir} failed"
2207
+ end
2208
+ def recursive_copy_and_rename(sourcedir, sourcefile, destname)
2209
+ tmpdir = tempdir(destname)
2210
+ recursive_copy(sourcedir, sourcefile, tmpdir)
2211
+ File.rename(File.join(tmpdir, sourcefile), destname)
2212
+ Dir.delete(tmpdir)
2213
+ end
2214
+
2215
+ def lock_file(file)
2216
+ lockpath = File.join(@lockbase, "#{file}.LOCK")
2217
+
2218
+ # Make sure the directory tree for this file exists in the
2219
+ # lock directory
2220
+ lockdir = File.dirname(lockpath)
2221
+ if ! File.directory?(lockdir)
2222
+ puts "Making directory tree #{lockdir}" if (@debug)
2223
+ FileUtils.mkpath(lockdir) if (!@dryrun)
2224
+ end
2225
+
2226
+ return if (@dryrun)
2227
+
2228
+ # Make 30 attempts (1s sleep after each attempt)
2229
+ 30.times do |i|
2230
+ begin
2231
+ fd = IO::sysopen(lockpath, Fcntl::O_WRONLY|Fcntl::O_CREAT|Fcntl::O_EXCL)
2232
+ puts "Lock acquired for #{file}" if (@debug)
2233
+ f = IO.open(fd) { |f| f.puts $$ }
2234
+ @locked_files[file] = true
2235
+ return
2236
+ rescue Errno::EEXIST
2237
+ puts "Attempt to acquire lock for #{file} failed, sleeping 1s"
2238
+ sleep 1
2239
+ end
2240
+ end
2241
+
2242
+ raise "Unable to acquire lock for #{file} after repeated attempts"
2243
+ end
2244
+
2245
+ def unlock_file(file)
2246
+ lockpath = File.join(@lockbase, "#{file}.LOCK")
2247
+
2248
+ # Since we don't create lock files in dry run mode the rest of this
2249
+ # method won't behave properly
2250
+ return if (@dryrun)
2251
+
2252
+ if File.exist?(lockpath)
2253
+ pid = nil
2254
+ File.open(lockpath) { |f| pid = f.gets.chomp.to_i }
2255
+ if pid == $$
2256
+ puts "Unlocking #{file}" if (@debug)
2257
+ File.delete(lockpath)
2258
+ @locked_files.delete(file)
2259
+ else
2260
+ # This shouldn't happen, if it does it's a bug
2261
+ raise "Process #{Process.pid} asked to unlock #{file} which is locked by another process (pid #{pid})"
2262
+ end
2263
+ else
2264
+ # This shouldn't happen either
2265
+ warn "Lock for #{file} lost"
2266
+ @locked_files.delete(file)
2267
+ end
2268
+ end
2269
+
2270
+ def unlock_all_files
2271
+ @locked_files.each_key { |file| unlock_file(file) }
2272
+ end
2273
+
2274
+ # Any etch lockfiles more than a couple hours old are most likely stale
2275
+ # and can be removed. If told to force we remove all lockfiles.
2276
+ def remove_stale_lock_files
2277
+ twohoursago = Time.at(Time.now - 60 * 60 * 2)
2278
+ Find.find(@lockbase) do |file|
2279
+ next unless file =~ /\.LOCK$/
2280
+ next unless File.file?(file)
2281
+
2282
+ if @lockforce || File.mtime(file) < twohoursago
2283
+ puts "Removing stale lock file #{file}"
2284
+ File.delete(file)
2285
+ end
2286
+ end
2287
+ end
2288
+
2289
+ def reset_already_processed
2290
+ @already_processed.clear
2291
+ end
2292
+
2293
+ # We limit capturing to 5 minutes. That should be plenty of time
2294
+ # for etch to handle any given file, including running any
2295
+ # setup/pre/post commands.
2296
+ OUTPUT_CAPTURE_TIMEOUT = 5 * 60
2297
+ def start_output_capture
2298
+ # Establish a pipe, spawn a child process, and redirect stdout/stderr
2299
+ # to the pipe. The child gathers up anything sent over the pipe and
2300
+ # when we close the pipe later it sends the captured output back to us
2301
+ # over a second pipe.
2302
+ pread, pwrite = IO.pipe
2303
+ oread, owrite = IO.pipe
2304
+ if fork
2305
+ # Parent
2306
+ pread.close
2307
+ owrite.close
2308
+ # Can't use $stdout and $stderr here, child processes don't
2309
+ # inherit them and process() spawns a variety of child
2310
+ # processes which have output we want to capture.
2311
+ oldstdout = STDOUT.dup
2312
+ oldstderr = STDERR.dup
2313
+ STDOUT.reopen(pwrite)
2314
+ STDERR.reopen(pwrite)
2315
+ pwrite.close
2316
+ @output_pipes << [oread, oldstdout, oldstderr]
2317
+ else
2318
+ # Child
2319
+ # We need to catch any exceptions in the child because the parent
2320
+ # might spawn this process in the context of a begin block (in fact
2321
+ # it does at the time of this writing), in which case if we throw an
2322
+ # exception here execution will jump back to that block, therefore not
2323
+ # exiting the child where we want but rather making a big mess of
2324
+ # things by continuing to execute the main body of code in parallel
2325
+ # with the parent process.
2326
+ begin
2327
+ pwrite.close
2328
+ oread.close
2329
+ # If we're somewhere past the first level of the recursion in
2330
+ # processing files the stdout/stderr we inherit from our parent will
2331
+ # actually be a pipe to the previous file's child process. We want
2332
+ # every child process to talk directly to the real filehandles,
2333
+ # otherwise every file that has dependencies will end up with the
2334
+ # output for those dependencies gathered with its output.
2335
+ STDOUT.reopen(ORIG_STDOUT)
2336
+ STDERR.reopen(ORIG_STDERR)
2337
+ # stdout is line buffered by default, so if we didn't enable sync here
2338
+ # then we wouldn't see the output of the putc below until we output a
2339
+ # newline.
2340
+ $stdout.sync = true
2341
+ output = ''
2342
+ begin
2343
+ # A surprising number of apps that we restart are ill-behaved and do
2344
+ # not properly close stdin/stdout/stderr. With etch's output
2345
+ # capturing feature this results in etch hanging around forever
2346
+ # waiting for the pipes to close. We time out after a suitable
2347
+ # period of time so that etch processes don't hang around forever.
2348
+ Timeout.timeout(OUTPUT_CAPTURE_TIMEOUT) do
2349
+ while char = pread.getc
2350
+ putc(char)
2351
+ output << char.chr
2352
+ end
2353
+ end
2354
+ rescue Timeout::Error
2355
+ $stderr.puts "Timeout in output capture, some app restarted via post probably didn't daemonize properly"
2356
+ end
2357
+ pread.close
2358
+ owrite.write(output)
2359
+ owrite.close
2360
+ rescue Exception => e
2361
+ $stderr.puts "Exception in output capture child: " + e.message
2362
+ $stderr.puts e.backtrace.join("\n") if @debug
2363
+ end
2364
+ # Exit in such a way that we don't trigger any signal handlers that
2365
+ # we might have inherited from the parent process
2366
+ exit!
2367
+ end
2368
+ end
2369
+ def stop_output_capture
2370
+ oread, oldstdout, oldstderr = @output_pipes.pop
2371
+ # The reopen and close closes the parent's end of the pipe to the child
2372
+ STDOUT.reopen(oldstdout)
2373
+ STDERR.reopen(oldstderr)
2374
+ oldstdout.close
2375
+ oldstderr.close
2376
+ # Which triggers the child to send us the gathered output over the
2377
+ # second pipe
2378
+ output = oread.read
2379
+ oread.close
2380
+ # And then the child exits
2381
+ Process.wait
2382
+ output
2383
+ end
2384
+
2385
+ def get_private_key_path
2386
+ key = nil
2387
+ PRIVATE_KEY_PATHS.each do |path|
2388
+ if File.readable?(path)
2389
+ key = path
2390
+ break
2391
+ end
2392
+ end
2393
+ if !key
2394
+ warn "No readable private key found, messages to server will not be signed and may be rejected depending on server configuration"
2395
+ end
2396
+ key
2397
+ end
2398
+
2399
+ # This method takes in a Net::HTTP::Post and a path to a private key.
2400
+ # It will insert a 'timestamp' parameter to the post body, hash the body of
2401
+ # the post, sign the hash using the private key, and insert that signature
2402
+ # in the HTTP Authorization header field in the post.
2403
+ def sign_post!(post, key)
2404
+ if key
2405
+ post.body << "&timestamp=#{CGI.escape(Time.now.to_s)}"
2406
+ private_key = OpenSSL::PKey::RSA.new(File.read(key))
2407
+ hashed_body = Digest::SHA1.hexdigest(post.body)
2408
+ signature = Base64.encode64(private_key.private_encrypt(hashed_body))
2409
+ # encode64 breaks lines at 60 characters with newlines. Having newlines
2410
+ # in an HTTP header screws things up (the lines get interpreted as
2411
+ # separate headers) so strip them out. The Base64 standards seem to
2412
+ # generally have a limit on line length, but Ruby's decode64 doesn't
2413
+ # seem to complain. If it ever becomes a problem the server could
2414
+ # rebreak the lines.
2415
+ signature.gsub!("\n", '')
2416
+ post['Authorization'] = "EtchSignature #{signature}"
2417
+ end
2418
+ end
2419
+ end
2420
+