etch 3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/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
+