cronicle 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ class Hash
2
+ def assert_valid_keys(*valid_keys)
3
+ each_key do |k|
4
+ next if valid_keys.include?(k)
5
+ raise ArgumentError, "Unknown key: #{k.inspect}. Valid keys are: #{valid_keys.map(&:inspect).join(', ')}"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,147 @@
1
+ SSHKit::Backend::Netssh.config.pty = true
2
+
3
+ class SSHKit::Backend::Netssh
4
+ def sudo(command, *args)
5
+ opts = args.last.kind_of?(Hash) ? args.pop : {}
6
+
7
+ password = host.options[:sudo_password] || ''
8
+ password = Shellwords.shellescape(password)
9
+
10
+ with_sudo = [:echo, password, '|', :sudo, '-S']
11
+ with_sudo << '-u' << opts[:user] if opts[:user]
12
+ with_sudo.concat(args)
13
+
14
+ raise_on_non_zero_exit = opts.fetch(:raise_on_non_zero_exit, true)
15
+ retval = send(command, *with_sudo, :raise_on_non_zero_exit => raise_on_non_zero_exit)
16
+ Cronicle::Utils.remove_prompt!(retval) if retval.kind_of?(String)
17
+ retval
18
+ end
19
+
20
+ CRON_DIRS = %w(/var/spool/cron/crontabs /var/spool/cron)
21
+
22
+ def find_cron_dir
23
+ @cron_dir ||= CRON_DIRS.find do |path|
24
+ execute(:test, '-d', path, :raise_on_non_zero_exit => false)
25
+ end
26
+
27
+ unless @cron_dir
28
+ raise "Cannot find cron directory: #{CRON_DIRS.join(', ')}"
29
+ end
30
+
31
+ @cron_dir
32
+ end
33
+
34
+ def list_crontabs
35
+ cron_dir = find_cron_dir
36
+ @crontab_list ||= sudo(:capture, :find, cron_dir, '-type', :f, '-maxdepth', 1, '2> /dev/null',
37
+ :raise_on_non_zero_exit => false).each_line.map(&:strip)
38
+ end
39
+
40
+ def fetch_crontabs
41
+ return @crontabs if @crontabs
42
+
43
+ @crontabs = {}
44
+
45
+ list_crontabs.each do |path|
46
+ user = File.basename(path)
47
+ crontab = sudo(:capture, :cat, path)
48
+ @crontabs[user] = crontab
49
+ end
50
+
51
+ @crontabs
52
+ end
53
+
54
+ def list_libexec_scripts
55
+ @libexec_scripts ||= capture(:find, libexec_dir, '-type', :f, '2> /dev/null',
56
+ :raise_on_non_zero_exit => false).each_line.map(&:strip)
57
+ end
58
+
59
+ def fetch_libexec_scripts
60
+ script_contents = {}
61
+
62
+ list_libexec_scripts.each do |script|
63
+ script_contents[script] = crlf_to_lf(capture(:cat, script))
64
+ end
65
+
66
+ script_contents
67
+ end
68
+
69
+ def delete_cron_entry(user, name = nil)
70
+ sed_cmd = '/' + Cronicle::Utils.sed_escape(script_path(user, name)) + ' /d'
71
+ sed_cmd = Shellwords.shellescape(sed_cmd)
72
+
73
+ sudo(:execute, :sed, '-i', sed_cmd, user_crontab(user), :raise_on_non_zero_exit => false)
74
+ end
75
+
76
+ def add_cron_entry(user, name, schedule, temp_dir)
77
+ script = script_path(user, name)
78
+ temp_entry = [temp_dir, name + '.entry'].join('/')
79
+
80
+ cron_entry = "#{schedule}\\t#{script} 2>&1 | logger -t cronicle/#{user}/#{name}"
81
+ cron_entry = Shellwords.shellescape(cron_entry)
82
+ sudo(:execute, :echo, '-e', cron_entry, '>', temp_entry)
83
+
84
+ entry_cat = "cat #{temp_entry} >> #{user_crontab(user)}"
85
+ entry_cat = Shellwords.shellescape(entry_cat)
86
+ sudo(:execute, :bash, '-c', entry_cat)
87
+ end
88
+
89
+ def upload_script(temp_dir, name, content)
90
+ temp_script = [temp_dir, name].join
91
+ upload!(StringIO.new(content), temp_script)
92
+ execute(:chmod, 755, temp_script)
93
+ yield(temp_script)
94
+ end
95
+
96
+ def mktemp(user = nil)
97
+ temp_dir = capture(:mktemp, '-d', '/var/tmp/cronicle.XXXXXXXXXX')
98
+ block_args = [temp_dir]
99
+
100
+ begin
101
+ execute(:chmod, 755, temp_dir)
102
+
103
+ if user
104
+ user_temp_dir = [temp_dir, user].join('/')
105
+ execute(:mkdir, '-p', user_temp_dir)
106
+ execute(:chmod, 755, user_temp_dir)
107
+ block_args << user_temp_dir
108
+ end
109
+
110
+ yield(*block_args)
111
+ ensure
112
+ execute(:rm, '-rf', temp_dir, :raise_on_non_zero_exit => false) rescue nil
113
+ end
114
+ end
115
+
116
+ def libexec_dir
117
+ host.options.fetch(:libexec)
118
+ end
119
+
120
+ def user_libexec_dir(user)
121
+ [libexec_dir, user].join('/')
122
+ end
123
+
124
+ def user_crontab(user)
125
+ cron_dir = find_cron_dir
126
+ [cron_dir, user].join('/')
127
+ end
128
+
129
+ def script_path(user, name)
130
+ [libexec_dir, user, name].join('/')
131
+ end
132
+
133
+ def log_for_cronicle(level, message, opts = {})
134
+ opts = host.options.merge(opts)
135
+ Cronicle::Logger.log(level, message, opts)
136
+ end
137
+
138
+ private
139
+
140
+ def crlf_to_lf(str)
141
+ str.gsub("\r\n", "\n")
142
+ end
143
+ end
144
+
145
+ class SSHKit::Host
146
+ attr_reader :options
147
+ end
@@ -0,0 +1,11 @@
1
+ class String
2
+ def undent
3
+ min_space_num = self.split("\n").delete_if {|s| s =~ /^\s*$/ }.map {|s| (s[/^\s+/] || '').length }.min
4
+
5
+ if min_space_num and min_space_num > 0
6
+ gsub(/^[ \t]{,#{min_space_num}}/, '')
7
+ else
8
+ self
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,89 @@
1
+ class Cronicle::HostList
2
+ def initialize(src, options = {})
3
+ @hosts = Set.new
4
+ @host_by_role = Hash.new {|h, k| h[k] = Set.new }
5
+
6
+ options.assert_valid_keys(:roles)
7
+ target_roles = Cronicle::Utils.regexp_union(options[:roles])
8
+
9
+ begin
10
+ host_list = JSON.parse(src)
11
+ rescue JSON::ParserError
12
+ host_list = {'servers' => {}}
13
+
14
+ src.split(/[,\s]+/).each do |host|
15
+ host.strip!
16
+ host_list['servers'][host] = [] unless host.empty?
17
+ end
18
+ end
19
+
20
+ host_list.assert_valid_keys('servers', 'roles')
21
+ servers = host_list['servers'] || {}
22
+ roles = host_list['roles'] || {}
23
+
24
+ unless roles.kind_of?(Hash)
25
+ raise TypeError, "wrong roles type #{roles.class} (expected Hash)"
26
+ end
27
+
28
+ initialize_servers(servers, target_roles)
29
+ initialize_roles(roles, target_roles)
30
+ end
31
+
32
+ def all
33
+ @hosts.to_a
34
+ end
35
+
36
+ def select(options = {})
37
+ options.assert_valid_keys(:servers, :roles)
38
+ target_servers, target_roles = options.values_at(:servers, :roles)
39
+
40
+ target_servers = Cronicle::Utils.regexp_union(target_servers)
41
+ target_roles = Cronicle::Utils.regexp_union(target_roles)
42
+
43
+ host_set = Set.new
44
+
45
+ @hosts.each do |host|
46
+ host_set << host if host =~ target_servers
47
+ end
48
+
49
+ @host_by_role.each do |role, hosts|
50
+ host_set.merge(hosts) if role =~ target_roles
51
+ end
52
+
53
+ host_set.to_a
54
+ end
55
+
56
+ private
57
+
58
+ def initialize_servers(servers, target_roles)
59
+ unless servers.kind_of?(Hash)
60
+ servers_hash = {}
61
+
62
+ Array(servers).each do |host|
63
+ servers_hash[host.to_s] = []
64
+ end
65
+
66
+ servers = servers_hash
67
+ end
68
+
69
+ servers.each do |host, roles|
70
+ roles = Array(roles).map(&:to_s)
71
+
72
+ if target_roles.nil? or roles.any? {|r| r =~ target_servers }
73
+ @hosts << host
74
+ roles.each {|r| @host_by_role[r] << host }
75
+ end
76
+ end
77
+ end
78
+
79
+ def initialize_roles(roles, target_roles)
80
+ roles.each do |role, hosts|
81
+ hosts = Array(hosts).map(&:to_s)
82
+
83
+ if target_roles.nil? or roles.any? {|r| r =~ target_servers }
84
+ @hosts.merge(hosts)
85
+ @host_by_role[role].merge(hosts)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,92 @@
1
+ class Cronicle::Logger < ::Logger
2
+ include Singleton
3
+
4
+ def initialize
5
+ super($stdout)
6
+
7
+ self.formatter = proc do |severity, datetime, progname, msg|
8
+ "#{msg}\n"
9
+ end
10
+
11
+ self.level = INFO
12
+ end
13
+
14
+ def set_debug(value)
15
+ if value
16
+ self.level = DEBUG
17
+ SSHKit.config.output_verbosity = :debug
18
+ else
19
+ self.level = INFO
20
+ SSHKit.config.output_verbosity = :warn
21
+ end
22
+ end
23
+
24
+ class << self
25
+ def log(level, message, opts = {})
26
+ message = "#{level.to_s.downcase}: #{message}" unless level == :info
27
+ message << ' (dry-run)' if opts[:dry_run]
28
+ message.gsub!(/\s+\z/, '')
29
+ message = message.send(opts[:color]) if opts[:color]
30
+
31
+ job_info = ''
32
+
33
+ if opts[:job]
34
+ job_info << opts[:job]
35
+ end
36
+
37
+ host_user = [:host, :user].map {|key|
38
+ value = opts[key]
39
+ value = Cronicle::Utils.short_hostname(value) if key == :host
40
+ value
41
+ }.compact
42
+
43
+ unless host_user.empty?
44
+ job_info << ' on ' unless job_info.empty?
45
+ job_info << host_user.join('/')
46
+ end
47
+
48
+ unless job_info.empty?
49
+ job_info = "#{job_info}>".light_black
50
+ message = "#{job_info} #{message}"
51
+ end
52
+
53
+ logger = opts[:logger] || Cronicle::Logger.instance
54
+ logger.send(level, message)
55
+ end
56
+ end # of class methods
57
+
58
+ # XXX:
59
+ module Helper
60
+ def log(level, message, opts = {})
61
+ opts = (@options || {}).merge(opts)
62
+ Cronicle::Logger.log(level, message, opts)
63
+ end
64
+ end
65
+
66
+ class SSHKitIO
67
+ def initialize(io = $stdout)
68
+ @io = io
69
+ end
70
+
71
+ def <<(obj)
72
+ @io << mask_password(obj)
73
+ end
74
+
75
+ private
76
+
77
+ MASK_REGEXP = /\becho\s+([^|]+)\s+\|\s+sudo\s+-S\s+/
78
+ MASK = 'XXXXXXXX'
79
+
80
+ def mask_password(obj)
81
+ if obj.kind_of?(String) and obj =~ MASK_REGEXP
82
+ password = $1
83
+ obj.sub(password, MASK)
84
+ else
85
+ obj
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ SSHKit.config.output = SSHKit::Formatter::Pretty.new(Cronicle::Logger::SSHKitIO.new)
92
+ SSHKit.config.output_verbosity = :warn
@@ -0,0 +1,43 @@
1
+ class Cronicle::Utils
2
+ class << self
3
+ def regexp_union(list)
4
+ return nil if list.nil?
5
+ return list if list.kind_of?(Regexp)
6
+
7
+ list = Array(list)
8
+ return nil if list.empty?
9
+
10
+ Regexp.union(list.map {|str_or_reg|
11
+ if str_or_reg.kind_of?(Regexp)
12
+ str_or_reg
13
+ else
14
+ /\A#{str_or_reg}\z/
15
+ end
16
+ })
17
+ end
18
+
19
+ IPADDR_REGEXP = /\A\d+(?:\.\d+){3}\z/
20
+
21
+ def short_hostname(hostname)
22
+ if hostname =~ IPADDR_REGEXP
23
+ hostname
24
+ else
25
+ hostname.split('.').first
26
+ end
27
+ end
28
+
29
+ def sed_escape(cmd)
30
+ cmd.gsub('/', '\\/')
31
+ end
32
+
33
+ def remove_prompt!(str)
34
+ str.sub!(/\A[^:]*:\s*/, '')
35
+ end
36
+
37
+ def diff(file1, file2)
38
+ file1 = file1.chomp + "\n"
39
+ file2 = file2.chomp + "\n"
40
+ Diffy::Diff.new(file1, file2).to_s(:text)
41
+ end
42
+ end # of class methods
43
+ end
@@ -0,0 +1,3 @@
1
+ module Cronicle
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,295 @@
1
+ describe 'Cronicle::Client#apply (create)' do
2
+ context 'when empty cron' do
3
+ let(:jobfile) do
4
+ <<-RUBY.undent
5
+ on servers: /.*/ do
6
+ job :foo, user: :root, schedule: '1 2 * * *' do
7
+ puts `uname`
8
+ puts `whoami`
9
+ end
10
+ end
11
+
12
+ on servers: /.*/ do
13
+ job :bar, user: :root, schedule: :@hourly, content: <<-SH.undent
14
+ #!/bin/sh
15
+ echo hello
16
+ SH
17
+ end
18
+
19
+ on servers: /amazon_linux/ do
20
+ job :foo, user: 'ec2-user', schedule: '1 * * * *' do
21
+ puts 100
22
+ end
23
+ end
24
+
25
+ on servers: /ubuntu/ do
26
+ job :foo, user: :ubuntu, schedule: :@daily do
27
+ puts 200
28
+ end
29
+ end
30
+ RUBY
31
+ end
32
+
33
+ let(:amzn_crontab) do
34
+ {
35
+ "/var/spool/cron/ec2-user" =>
36
+ "1 * * * *\t/var/lib/cronicle/libexec/ec2-user/foo 2>&1 | logger -t cronicle/ec2-user/foo
37
+ ",
38
+ "/var/spool/cron/root" =>
39
+ "1 2 * * *\t/var/lib/cronicle/libexec/root/foo 2>&1 | logger -t cronicle/root/foo
40
+ @hourly\t/var/lib/cronicle/libexec/root/bar 2>&1 | logger -t cronicle/root/bar
41
+ "
42
+ }
43
+ end
44
+
45
+ let(:ubuntu_crontab) do
46
+ {
47
+ "/var/spool/cron/crontabs/root" =>
48
+ "1 2 * * *\t/var/lib/cronicle/libexec/root/foo 2>&1 | logger -t cronicle/root/foo
49
+ @hourly\t/var/lib/cronicle/libexec/root/bar 2>&1 | logger -t cronicle/root/bar
50
+ ",
51
+ "/var/spool/cron/crontabs/ubuntu" =>
52
+ "@daily\t/var/lib/cronicle/libexec/ubuntu/foo 2>&1 | logger -t cronicle/ubuntu/foo
53
+ "
54
+ }
55
+ end
56
+
57
+ before do
58
+ cronicle(:apply) { jobfile }
59
+ end
60
+
61
+ it do
62
+ on :amazon_linux do
63
+ expect(get_uname).to match /amzn/
64
+ expect(get_crontabs).to eq amzn_crontab
65
+
66
+ expect(get_file('/var/lib/cronicle/libexec/root/foo')).to eq <<-EOS.undent
67
+ #!/usr/bin/env ruby
68
+ puts `uname`
69
+ puts `whoami`
70
+ EOS
71
+
72
+ expect(get_file('/var/lib/cronicle/libexec/root/bar')).to eq <<-EOS.undent
73
+ #!/bin/sh
74
+ echo hello
75
+ EOS
76
+
77
+ expect(get_file('/var/lib/cronicle/libexec/ec2-user/foo')).to eq <<-EOS.undent
78
+ #!/usr/bin/env ruby
79
+ puts 100
80
+ EOS
81
+ end
82
+ end
83
+
84
+ it do
85
+ on :ubuntu do
86
+ expect(get_uname).to match /Ubuntu/
87
+ expect(get_crontabs).to eq ubuntu_crontab
88
+
89
+ expect(get_file('/var/lib/cronicle/libexec/root/foo')).to eq <<-EOS.undent
90
+ #!/usr/bin/env ruby
91
+ puts `uname`
92
+ puts `whoami`
93
+ EOS
94
+
95
+ expect(get_file('/var/lib/cronicle/libexec/root/bar')).to eq <<-EOS.undent
96
+ #!/bin/sh
97
+ echo hello
98
+ EOS
99
+
100
+ expect(get_file('/var/lib/cronicle/libexec/ubuntu/foo')).to eq <<-EOS.undent
101
+ #!/usr/bin/env ruby
102
+ puts 200
103
+ EOS
104
+ end
105
+ end
106
+ end
107
+
108
+ context 'when default cron' do
109
+ before do
110
+ on TARGET_HOSTS do |ssh_options|
111
+ user = ssh_options[:user]
112
+
113
+ set_crontab user, <<-CRON.undent
114
+ FOO=bar
115
+ ZOO=baz
116
+ 1 1 1 1 1 echo #{user} > /dev/null
117
+ CRON
118
+
119
+ set_crontab :root, <<-CRON.undent
120
+ FOO=bar
121
+ ZOO=baz
122
+ 1 1 1 1 1 echo root > /dev/null
123
+ CRON
124
+ end
125
+ end
126
+
127
+ let(:jobfile) do
128
+ <<-RUBY.undent
129
+ on servers: /.*/ do
130
+ job :foo, user: :root, schedule: '1 2 * * *' do
131
+ puts `uname`
132
+ puts `whoami`
133
+ end
134
+ end
135
+
136
+ on servers: /.*/ do
137
+ job :bar, user: :root, schedule: :@hourly, content: <<-SH.undent
138
+ #!/bin/sh
139
+ echo hello
140
+ SH
141
+ end
142
+
143
+ on servers: /amazon_linux/ do
144
+ job :foo, user: 'ec2-user', schedule: '1 * * * *' do
145
+ puts 100
146
+ end
147
+ end
148
+
149
+ on servers: /ubuntu/ do
150
+ job :foo, user: :ubuntu, schedule: :@daily do
151
+ puts 200
152
+ end
153
+ end
154
+ RUBY
155
+ end
156
+
157
+ context 'when apply' do
158
+ let(:amzn_crontab) do
159
+ {
160
+ "/var/spool/cron/ec2-user" =>
161
+ "FOO=bar
162
+ ZOO=baz
163
+ 1 1 1 1 1 echo ec2-user > /dev/null
164
+ 1 * * * *\t/var/lib/cronicle/libexec/ec2-user/foo 2>&1 | logger -t cronicle/ec2-user/foo
165
+ ",
166
+ "/var/spool/cron/root" =>
167
+ "FOO=bar
168
+ ZOO=baz
169
+ 1 1 1 1 1 echo root > /dev/null
170
+ 1 2 * * *\t/var/lib/cronicle/libexec/root/foo 2>&1 | logger -t cronicle/root/foo
171
+ @hourly\t/var/lib/cronicle/libexec/root/bar 2>&1 | logger -t cronicle/root/bar
172
+ "
173
+ }
174
+ end
175
+
176
+ let(:ubuntu_crontab) do
177
+ {
178
+ "/var/spool/cron/crontabs/root" =>
179
+ "FOO=bar
180
+ ZOO=baz
181
+ 1 1 1 1 1 echo root > /dev/null
182
+ 1 2 * * *\t/var/lib/cronicle/libexec/root/foo 2>&1 | logger -t cronicle/root/foo
183
+ @hourly\t/var/lib/cronicle/libexec/root/bar 2>&1 | logger -t cronicle/root/bar
184
+ ",
185
+ "/var/spool/cron/crontabs/ubuntu" =>
186
+ "FOO=bar
187
+ ZOO=baz
188
+ 1 1 1 1 1 echo ubuntu > /dev/null
189
+ @daily\t/var/lib/cronicle/libexec/ubuntu/foo 2>&1 | logger -t cronicle/ubuntu/foo
190
+ "
191
+ }
192
+ end
193
+
194
+ before do
195
+ cronicle(:apply) { jobfile }
196
+ end
197
+
198
+ it do
199
+ on :amazon_linux do
200
+ expect(get_uname).to match /amzn/
201
+ expect(get_crontabs).to eq amzn_crontab
202
+
203
+ expect(get_file('/var/lib/cronicle/libexec/root/foo')).to eq <<-EOS.undent
204
+ #!/usr/bin/env ruby
205
+ puts `uname`
206
+ puts `whoami`
207
+ EOS
208
+
209
+ expect(get_file('/var/lib/cronicle/libexec/root/bar')).to eq <<-EOS.undent
210
+ #!/bin/sh
211
+ echo hello
212
+ EOS
213
+
214
+ expect(get_file('/var/lib/cronicle/libexec/ec2-user/foo')).to eq <<-EOS.undent
215
+ #!/usr/bin/env ruby
216
+ puts 100
217
+ EOS
218
+ end
219
+ end
220
+
221
+ it do
222
+ on :ubuntu do
223
+ expect(get_uname).to match /Ubuntu/
224
+ expect(get_crontabs).to eq ubuntu_crontab
225
+
226
+ expect(get_file('/var/lib/cronicle/libexec/root/foo')).to eq <<-EOS.undent
227
+ #!/usr/bin/env ruby
228
+ puts `uname`
229
+ puts `whoami`
230
+ EOS
231
+
232
+ expect(get_file('/var/lib/cronicle/libexec/root/bar')).to eq <<-EOS.undent
233
+ #!/bin/sh
234
+ echo hello
235
+ EOS
236
+
237
+ expect(get_file('/var/lib/cronicle/libexec/ubuntu/foo')).to eq <<-EOS.undent
238
+ #!/usr/bin/env ruby
239
+ puts 200
240
+ EOS
241
+ end
242
+ end
243
+ end
244
+
245
+ context 'when apply (dry-run)' do
246
+ let(:amzn_crontab) do
247
+ {
248
+ "/var/spool/cron/ec2-user" =>
249
+ "FOO=bar
250
+ ZOO=baz
251
+ 1 1 1 1 1 echo ec2-user > /dev/null
252
+ ",
253
+ "/var/spool/cron/root" =>
254
+ "FOO=bar
255
+ ZOO=baz
256
+ 1 1 1 1 1 echo root > /dev/null
257
+ "
258
+ }
259
+ end
260
+
261
+ let(:ubuntu_crontab) do
262
+ {
263
+ "/var/spool/cron/crontabs/root" =>
264
+ "FOO=bar
265
+ ZOO=baz
266
+ 1 1 1 1 1 echo root > /dev/null
267
+ ",
268
+ "/var/spool/cron/crontabs/ubuntu" =>
269
+ "FOO=bar
270
+ ZOO=baz
271
+ 1 1 1 1 1 echo ubuntu > /dev/null
272
+ "
273
+ }
274
+ end
275
+
276
+ before do
277
+ cronicle(:apply, dry_run: true) { jobfile }
278
+ end
279
+
280
+ it do
281
+ on :amazon_linux do
282
+ expect(get_uname).to match /amzn/
283
+ expect(get_crontabs).to eq amzn_crontab
284
+ end
285
+ end
286
+
287
+ it do
288
+ on :ubuntu do
289
+ expect(get_uname).to match /Ubuntu/
290
+ expect(get_crontabs).to eq ubuntu_crontab
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end