cronicle 0.1.0

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.
@@ -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