makitzo 0.1.2

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