makitzo 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. data/Gemfile +12 -0
  2. data/Gemfile.lock +27 -0
  3. data/LICENSE.txt +20 -0
  4. data/README.mdown +22 -0
  5. data/RESOURCES +2 -0
  6. data/Rakefile +51 -0
  7. data/VERSION +1 -0
  8. data/bin/makitzo +6 -0
  9. data/lib/makitzo/application.rb +151 -0
  10. data/lib/makitzo/application_aware.rb +19 -0
  11. data/lib/makitzo/cli.rb +71 -0
  12. data/lib/makitzo/config.rb +148 -0
  13. data/lib/makitzo/file_system.rb +24 -0
  14. data/lib/makitzo/logging/blackhole.rb +16 -0
  15. data/lib/makitzo/logging/collector.rb +170 -0
  16. data/lib/makitzo/logging/colorize.rb +38 -0
  17. data/lib/makitzo/memoized_proc.rb +15 -0
  18. data/lib/makitzo/migrations/commands.rb +11 -0
  19. data/lib/makitzo/migrations/generator.rb +24 -0
  20. data/lib/makitzo/migrations/migration.rb +69 -0
  21. data/lib/makitzo/migrations/migrator.rb +87 -0
  22. data/lib/makitzo/migrations/paths.rb +7 -0
  23. data/lib/makitzo/monkeys/array.rb +10 -0
  24. data/lib/makitzo/monkeys/bangify.rb +15 -0
  25. data/lib/makitzo/monkeys/net-ssh.rb +73 -0
  26. data/lib/makitzo/monkeys/string.rb +15 -0
  27. data/lib/makitzo/multiplexed_reader.rb +26 -0
  28. data/lib/makitzo/settings.rb +30 -0
  29. data/lib/makitzo/ssh/commands/apple.rb +104 -0
  30. data/lib/makitzo/ssh/commands/file_system.rb +59 -0
  31. data/lib/makitzo/ssh/commands/file_transfer.rb +9 -0
  32. data/lib/makitzo/ssh/commands/http.rb +51 -0
  33. data/lib/makitzo/ssh/commands/makitzo.rb +46 -0
  34. data/lib/makitzo/ssh/commands/ruby.rb +18 -0
  35. data/lib/makitzo/ssh/commands/unix.rb +7 -0
  36. data/lib/makitzo/ssh/context.rb +91 -0
  37. data/lib/makitzo/ssh/multi.rb +79 -0
  38. data/lib/makitzo/store/mysql.rb +176 -0
  39. data/lib/makitzo/store/skeleton.rb +46 -0
  40. data/lib/makitzo/world/host.rb +84 -0
  41. data/lib/makitzo/world/named_entity.rb +41 -0
  42. data/lib/makitzo/world/query.rb +54 -0
  43. data/lib/makitzo/world/role.rb +4 -0
  44. data/lib/makitzo.rb +90 -0
  45. data/makitzo.gemspec +106 -0
  46. data/templates/migration.erb +9 -0
  47. data/test/helper.rb +17 -0
  48. data/test/test_makitzo.rb +7 -0
  49. metadata +222 -0
@@ -0,0 +1,170 @@
1
+ module Makitzo; module Logging
2
+ THREAD_HOST_KEY = 'makitzo.logging.host'
3
+
4
+ # A logger which collects all log messages and displays a summary by host
5
+ class Collector
6
+ include Colorize
7
+
8
+ attr_accessor :use_color
9
+ def use_color?; !!@use_color; end
10
+
11
+ def initialize
12
+ @use_color = true
13
+ @host = nil
14
+ @messages = []
15
+ @hosts = Hash.new { |h,k| h[k] = {:error => false, :messages => []} }
16
+ @lock = Mutex.new
17
+ @silenced = false
18
+ end
19
+
20
+ # This method is not threadsafe. So call it before spawning threads.
21
+ def silence(&block)
22
+ begin
23
+ was_silenced = @silenced
24
+ @silenced = true
25
+ yield if block_given?
26
+ ensure
27
+ @silenced = was_silenced
28
+ end
29
+ end
30
+
31
+ def with_host(host, &block)
32
+ return unless block_given?
33
+
34
+ begin
35
+ set_current_host(host)
36
+ info("host is #{host.address}")
37
+ yield
38
+ ensure
39
+ set_current_host(nil)
40
+ end
41
+
42
+ nil
43
+ end
44
+
45
+ # logs a command
46
+ # options[:command] - override command line to be logged. useful for masking passwords.
47
+ def log_command(status, options = {})
48
+ command = options[:command] || status.command
49
+
50
+ log_command_line(command, status.success?)
51
+ log_command_status(status)
52
+
53
+ overall_error! if current_host && !status.success?
54
+ end
55
+
56
+ def log_command_line(command, success = true)
57
+ if command.is_a?(Net::SSH::Connection::Session::ExecStatus)
58
+ success = command.success?
59
+ command = command.command
60
+ end
61
+ if success
62
+ append green("$", true), " ", green(sanitize(command))
63
+ else
64
+ append red("$", true), " ", red(sanitize(command))
65
+ end
66
+ end
67
+
68
+ def log_command_status(result, success = true)
69
+ if result.is_a?(Net::SSH::Connection::Session::ExecStatus)
70
+ success = result.success?
71
+ result = (success ? result.stdout : result.stderr).last_line.strip
72
+ end
73
+ unless result.empty?
74
+ if success
75
+ append green("-", true), " ", green(sanitize(result))
76
+ else
77
+ append red("!", true), " ", red(sanitize(result))
78
+ end
79
+ end
80
+ end
81
+
82
+ def overall_success!
83
+ raise "Cannot log host success when no host is set" unless current_host
84
+ @hosts[current_host.to_s][:error] = false
85
+ end
86
+
87
+ def overall_error!
88
+ raise "Cannot log host error when no host is set" unless current_host
89
+ @hosts[current_host.to_s][:error] = true
90
+ end
91
+
92
+ def error(message)
93
+ append red("[ERROR]", true), ' ', red(sanitize(message))
94
+ overall_error! if current_host
95
+ end
96
+
97
+ def success(message)
98
+ append green("[OK]", true), ' ', sanitize(message)
99
+ end
100
+
101
+ def notice(message)
102
+ append cyan("[NOTICE]", true), ' ', sanitize(message)
103
+ end
104
+
105
+ def warn(message)
106
+ append yellow("[WARNING]", true), ' ', sanitize(message)
107
+ end
108
+
109
+ def info(message)
110
+ append '[INFO]', ' ', sanitize(message)
111
+ end
112
+
113
+ def debug(message)
114
+ append blue('[DEBUG]', true), ' ', sanitize(message)
115
+ end
116
+
117
+ def collector?
118
+ true
119
+ end
120
+
121
+ def result
122
+ out = ""
123
+
124
+ @hosts.keys.sort.each do |host_name|
125
+ host_status = @hosts[host_name]
126
+ next if host_status[:messages].empty?
127
+ out << magenta('* ' + host_name, true) << " " << (host_status[:error] ? red('[ERROR]', true) : green('[OK]', true)) << "\n"
128
+ host_status[:messages].each { |m| out << m.indent(2) << "\n" }
129
+ out << "\n"
130
+ end
131
+
132
+ unless @messages.empty?
133
+ out << magenta("* Global Messages", true) << "\n"
134
+ @messages.each { |m| out << m.indent(2) << "\n" }
135
+ out << "\n"
136
+ end
137
+
138
+ out
139
+ end
140
+
141
+ def append(*chunks)
142
+ unless @silenced
143
+ @lock.synchronize do
144
+ active_log << chunks.join('').strip
145
+ end
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def sanitize(message)
152
+ message.to_s
153
+ end
154
+
155
+ def active_log
156
+ current_host ? @hosts[current_host.to_s][:messages] : @messages
157
+ end
158
+
159
+ def current_host
160
+ Thread.current[THREAD_HOST_KEY]
161
+ end
162
+
163
+ def set_current_host(host)
164
+ raise "Cannot set host; host already set" if host && current_host
165
+ Thread.current[THREAD_HOST_KEY] = host
166
+ end
167
+
168
+ end
169
+
170
+ end; end
@@ -0,0 +1,38 @@
1
+ module Makitzo; module Logging
2
+ module Colorize
3
+ def bold(text)
4
+ if use_color?
5
+ "\033[1m#{text}\033[0m"
6
+ else
7
+ text
8
+ end
9
+ end
10
+
11
+ def colorize(text, ansi, bold = false)
12
+ if use_color?
13
+ code = "\033["
14
+ code << "1;" if bold
15
+ code << "#{ansi}m"
16
+ code + text + "\033[0m"
17
+ else
18
+ text
19
+ end
20
+ end
21
+
22
+ { :black => 30,
23
+ :red => 31,
24
+ :green => 32,
25
+ :yellow => 33,
26
+ :blue => 34,
27
+ :magenta => 35,
28
+ :cyan => 36,
29
+ :white => 37
30
+ }.each do |color, ansi|
31
+ class_eval <<-CODE
32
+ def #{color}(text, bold = false)
33
+ colorize(text, #{ansi}, bold)
34
+ end
35
+ CODE
36
+ end
37
+ end
38
+ end; end
@@ -0,0 +1,15 @@
1
+ module Makitzo
2
+ class MemoizedProc
3
+ def initialize(&block)
4
+ @proc = block
5
+ end
6
+
7
+ def call(*args)
8
+ if defined?(@value)
9
+ @value
10
+ else
11
+ @value = @proc.call(*args)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module Makitzo; module Migrations
2
+ module Commands
3
+ def upload_migration_file(name)
4
+ target = remote_migration_file(name)
5
+ scp_upload(local_migration_file(name), target)
6
+ target
7
+ end
8
+
9
+ bangify :upload_migration_file
10
+ end
11
+ end; end
@@ -0,0 +1,24 @@
1
+ module Makitzo; module Migrations
2
+ class Generator
3
+ include ApplicationAware
4
+ include Paths
5
+
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def create_migration(name)
11
+ @migration_name = name
12
+ @migration_timestamp = Time.now.to_i
13
+ @migration_directory = File.join(local_migration_path, "#{@migration_timestamp}_#{@migration_name}")
14
+ @migration_class_name = @migration_name.camelize
15
+
16
+ template = ERB.new(File.read(File.join(Makitzo::ROOT, 'templates', 'migration.erb')))
17
+
18
+ FileUtils.mkdir_p(@migration_directory)
19
+
20
+ migration_source = template.result(binding)
21
+ File.open(File.join(@migration_directory, 'migration.rb'), 'w') { |f| f.write(migration_source) }
22
+ end
23
+ end
24
+ end; end
@@ -0,0 +1,69 @@
1
+ module Makitzo; module Migrations
2
+ class Migration < Makitzo::SSH::Context
3
+ class << self
4
+ def timestamp; @timestamp; end
5
+ def timestamp=(ts); @timestamp = ts; end
6
+
7
+ def directory; @directory; end
8
+ def directory=(d); @directory = d; end
9
+
10
+ def roles(*roles)
11
+ @roles ||= []
12
+ @roles.concat([roles].flatten) unless roles.empty?
13
+ @roles
14
+ end
15
+
16
+ def hosts(*hosts)
17
+ @hosts ||= []
18
+ @hosts.concat([hosts].flatten) unless hosts.empty?
19
+ @hosts
20
+ end
21
+
22
+ def query
23
+ unless @query
24
+ @query = World::Query.new
25
+ roles.each { |r| @query.roles << r }
26
+ hosts.each { |h| @query.hosts << h }
27
+ end
28
+ @query
29
+ end
30
+
31
+ alias_method :role, :roles
32
+ alias_method :host, :hosts
33
+
34
+ # Returns an array of methods which are required by migrations.
35
+ # Used to prevent helpers from defining conflicting methods.
36
+ def protected_context_methods
37
+ %w(up down local_directory local_migration_file remote_directory remote_migration_file)
38
+ end
39
+ end
40
+
41
+ def local_directory
42
+ self.class.directory
43
+ end
44
+
45
+ def local_migration_file(file)
46
+ File.join(local_directory, file)
47
+ end
48
+
49
+ def remote_directory
50
+ File.join(host.migration_history_dir, self.class.timestamp.to_s)
51
+ end
52
+
53
+ def remote_migration_file(file)
54
+ File.join(remote_directory, file)
55
+ end
56
+
57
+ def up
58
+ raise UnsupportedMigrationError, "up direction is not defined!"
59
+ end
60
+
61
+ def down
62
+ raise UnsupportedMigrationError, "down direction is not defined!"
63
+ end
64
+
65
+ def to_i
66
+ self.class.timestamp
67
+ end
68
+ end
69
+ end; end
@@ -0,0 +1,87 @@
1
+ module Makitzo; module Migrations
2
+ class Migrator
3
+ include ApplicationAware
4
+ include SSH::Multi
5
+ include Paths
6
+
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def migrate(target_hosts)
12
+ all_migrations = migrations
13
+ return if all_migrations.empty?
14
+
15
+ # start with a query matching all hosts and reduce to set of hosts
16
+ # affected by existing migrations
17
+ migration_hosts_query = World::Query.all
18
+ all_migrations.each { |m| migration_hosts_query.merge!(m.query) }
19
+
20
+ # get list of hosts and intersect with hosts specified on command-line
21
+ migration_hosts = migration_hosts_query.exec(config)
22
+ migration_hosts &= target_hosts
23
+
24
+ # finally, remove any hosts with no pending migrations
25
+ applied_migrations = store.applied_migrations_for_all_hosts
26
+ migration_hosts.delete_if { |host|
27
+ all_migrations.all? { |m|
28
+ (applied_migrations[host.to_s] || []).include?(m.timestamp) || !m.query.includes?(host)
29
+ }
30
+ }
31
+
32
+ multi_ssh(migration_hosts) do |host, conn, error|
33
+ logger.with_host(host) do
34
+ if error
35
+ # log connection error
36
+ else
37
+ begin
38
+ overseer_session = ssh_context_class.new(host, conn)
39
+ overseer_session.makitzo_install_check!
40
+
41
+ # then select the appropriate migrations, create session classes and run
42
+ all_migrations.each do |migration_klass|
43
+ next if (applied_migrations[host.to_s] || []).include?(migration_klass.timestamp)
44
+ next unless migration_klass.query.includes?(host)
45
+ migration = migration_klass.new(host, conn)
46
+ migration.exec!("mkdir -p #{migration.x(migration.remote_directory)}")
47
+ migration.up
48
+ host.mark_migration_as_applied(migration)
49
+ logger.success "Migration #{migration_klass.timestamp} applied"
50
+ end
51
+ rescue SSH::CommandFailed => e
52
+ logger.error "Migration failed"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def migrations
62
+ unless @migrations
63
+ @migrations = Dir["#{local_migration_path}/*"].map do |candidate|
64
+ next unless File.directory?(candidate)
65
+ timestamp, *rest = File.basename(candidate).split('_')
66
+ class_name = rest.join('_').camelize
67
+ begin
68
+ require File.join(candidate, 'migration.rb')
69
+ klass = class_name.constantize
70
+ rescue LoadError => e
71
+ raise MigrationNotFound, "migration file not found: #{candidate}"
72
+ rescue NameError => e
73
+ raise MigrationNotFound, "expected #{candidate} to define #{class_name}"
74
+ end
75
+ klass.timestamp = timestamp.to_i
76
+ klass.directory = File.expand_path(candidate)
77
+ klass.send(:include, config.helpers)
78
+ klass
79
+ end
80
+ @migrations.compact!
81
+ @migrations.sort { |m1,m2| m1.timestamp <=> m2.timestamp }
82
+ end
83
+ @migrations
84
+ end
85
+
86
+ end
87
+ end; end
@@ -0,0 +1,7 @@
1
+ module Makitzo; module Migrations
2
+ module Paths
3
+ def local_migration_path
4
+ File.join(app.root_directory, 'migrations')
5
+ end
6
+ end
7
+ end; end
@@ -0,0 +1,10 @@
1
+ class Array
2
+ def in_groups_of(group_size)
3
+ out = []
4
+ each_with_index do |ele, i|
5
+ out << [] if i % group_size == 0
6
+ out.last << ele
7
+ end
8
+ out
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ class Module
2
+ def bangify(*methods)
3
+ exception_class = 'RuntimeError'
4
+ exception_class = methods.pop if (methods.last.is_a?(Class) || methods.last =~ /^[A-Z]/)
5
+ methods.each do |method|
6
+ class_eval <<-CODE
7
+ def #{method}!(*args, &block)
8
+ result = #{method}(*args, &block)
9
+ raise #{exception_class} unless result
10
+ result
11
+ end
12
+ CODE
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,73 @@
1
+ class Net::SSH::Connection::Session
2
+ class ExecStatus
3
+ attr_accessor :command, :stdout, :stderr, :exit_code, :exit_signal
4
+
5
+ def initialize
6
+ @command, @stdout, @stderr, @exit_code, @exit_signal = "", "", "", 0, 0, nil
7
+ end
8
+
9
+ def success?
10
+ @exit_code == 0
11
+ end
12
+
13
+ def error?
14
+ !success?
15
+ end
16
+
17
+ def to_i
18
+ @exit_code
19
+ end
20
+
21
+ def to_s
22
+ @stdout
23
+ end
24
+
25
+ def inspect
26
+ "<ExecStatus command=#{@command.inspect} stdout=#{@stdout.inspect} status=#{@exit_code}>"
27
+ end
28
+
29
+ def hash
30
+ "#{command}\n#{exit_code}\n#{stdout}".hash
31
+ end
32
+
33
+ def eql?(other)
34
+ other.is_a?(ExecStatus) && (command == other.command && exit_code == other.exit_code && stdout == other.stdout)
35
+ end
36
+ end
37
+
38
+ # Adapted from:
39
+ # http://stackoverflow.com/questions/3386233/how-to-get-exit-status-with-rubys-netssh-library
40
+ #
41
+ # FIXME: we're currently opening a channel per command which strikes me
42
+ # as inefficient and prevents, for example, working directory from
43
+ # persisting across requests.
44
+ def exec2!(command, options = {})
45
+ options = {:log => true}.update(options)
46
+
47
+ status = ExecStatus.new
48
+ status.command = command
49
+
50
+ ch = open_channel do |channel|
51
+ channel.exec(command) do |ch, success|
52
+ raise "could not execute command: #{command.inspect}" unless success
53
+
54
+ channel.on_data { |ch, data| status.stdout << data }
55
+ channel.on_extended_data { |ch, type, data| status.stderr << data }
56
+ channel.on_request("exit-status") { |ch,data| status.exit_code = data.read_long }
57
+ channel.on_request("exit-signal") { |ch,data| status.exit_signal = data.read_long }
58
+ end
59
+ end
60
+
61
+ self.loop
62
+
63
+ if options[:log] && self[:logger]
64
+ if options[:log].is_a?(String)
65
+ self[:logger].log_command(status, :command => options[:log])
66
+ else
67
+ self[:logger].log_command(status)
68
+ end
69
+ end
70
+
71
+ status
72
+ end
73
+ end
@@ -0,0 +1,15 @@
1
+ class String
2
+ def indent(spaces)
3
+ gsub(/^/, " " * spaces)
4
+ end
5
+
6
+ # returns last non-empty line of string, or the empty string if none exists
7
+ def last_line
8
+ lines = split("\n")
9
+ while line = lines.pop
10
+ line.strip!
11
+ return line unless line.length == 0
12
+ end
13
+ return ''
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ module Makitzo
2
+ # relays IO from a single source to multiple threads
3
+ # each thread will see the same input.
4
+ # necessarily has to store all data as set of reader threads is not known.
5
+ # possible solution: associate reader with ThreadGroup so we can access
6
+ # list of threads. can then keep track of threads that have read least/most
7
+ # data and discard anything that's no longer needed.
8
+ class MultiplexedReader
9
+ def initialize(io)
10
+ @io, @mutex, @state, @lines = io, Mutex.new, Hash.new { |h,k| h[k] = 0 }, []
11
+ end
12
+
13
+ def gets
14
+ @mutex.synchronize do
15
+ lines_read_by_thread = @state[Thread.current]
16
+ if lines_read_by_thread >= @lines.count
17
+ @lines << @io.gets
18
+ end
19
+ @state[Thread.current] = lines_read_by_thread + 1
20
+
21
+ line = @lines[lines_read_by_thread]
22
+ line ? line.dup : line
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ module Makitzo
2
+ # mixin providing classes with a settings hash
3
+ module Settings
4
+ def settings
5
+ @settings ||= {}
6
+ end
7
+
8
+ def [](key)
9
+ read(key)
10
+ end
11
+
12
+ def []=(key, value)
13
+ set(key, value)
14
+ end
15
+
16
+ def read(key, default = nil)
17
+ val = settings[key.to_sym]
18
+ val = val.call if val.respond_to?(:call)
19
+ val.nil? ? default : val
20
+ end
21
+
22
+ def set(key, value = nil, &block)
23
+ settings[key.to_sym] = block_given? ? block : value
24
+ end
25
+
26
+ def memo(key, &block)
27
+ set(key, MemoizedProc.new(&block))
28
+ end
29
+ end
30
+ end