etch 3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Rakefile +16 -0
- data/bin/etch +101 -0
- data/bin/etch_cron_wrapper +18 -0
- data/bin/etch_to_trunk +45 -0
- data/etc/ca.pem +1 -0
- data/etc/dhparams +9 -0
- data/lib/etch.rb +1391 -0
- data/lib/etchclient.rb +2420 -0
- data/lib/versiontype.rb +84 -0
- data/man/man8/etch.8 +204 -0
- metadata +78 -0
data/lib/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 << "×tamp=#{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
|
+
|