makitzo 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +12 -0
- data/Gemfile.lock +27 -0
- data/LICENSE.txt +20 -0
- data/README.mdown +22 -0
- data/RESOURCES +2 -0
- data/Rakefile +51 -0
- data/VERSION +1 -0
- data/bin/makitzo +6 -0
- data/lib/makitzo/application.rb +151 -0
- data/lib/makitzo/application_aware.rb +19 -0
- data/lib/makitzo/cli.rb +71 -0
- data/lib/makitzo/config.rb +148 -0
- data/lib/makitzo/file_system.rb +24 -0
- data/lib/makitzo/logging/blackhole.rb +16 -0
- data/lib/makitzo/logging/collector.rb +170 -0
- data/lib/makitzo/logging/colorize.rb +38 -0
- data/lib/makitzo/memoized_proc.rb +15 -0
- data/lib/makitzo/migrations/commands.rb +11 -0
- data/lib/makitzo/migrations/generator.rb +24 -0
- data/lib/makitzo/migrations/migration.rb +69 -0
- data/lib/makitzo/migrations/migrator.rb +87 -0
- data/lib/makitzo/migrations/paths.rb +7 -0
- data/lib/makitzo/monkeys/array.rb +10 -0
- data/lib/makitzo/monkeys/bangify.rb +15 -0
- data/lib/makitzo/monkeys/net-ssh.rb +73 -0
- data/lib/makitzo/monkeys/string.rb +15 -0
- data/lib/makitzo/multiplexed_reader.rb +26 -0
- data/lib/makitzo/settings.rb +30 -0
- data/lib/makitzo/ssh/commands/apple.rb +104 -0
- data/lib/makitzo/ssh/commands/file_system.rb +59 -0
- data/lib/makitzo/ssh/commands/file_transfer.rb +9 -0
- data/lib/makitzo/ssh/commands/http.rb +51 -0
- data/lib/makitzo/ssh/commands/makitzo.rb +46 -0
- data/lib/makitzo/ssh/commands/ruby.rb +18 -0
- data/lib/makitzo/ssh/commands/unix.rb +7 -0
- data/lib/makitzo/ssh/context.rb +91 -0
- data/lib/makitzo/ssh/multi.rb +79 -0
- data/lib/makitzo/store/mysql.rb +176 -0
- data/lib/makitzo/store/skeleton.rb +46 -0
- data/lib/makitzo/world/host.rb +84 -0
- data/lib/makitzo/world/named_entity.rb +41 -0
- data/lib/makitzo/world/query.rb +54 -0
- data/lib/makitzo/world/role.rb +4 -0
- data/lib/makitzo.rb +90 -0
- data/makitzo.gemspec +106 -0
- data/templates/migration.erb +9 -0
- data/test/helper.rb +17 -0
- data/test/test_makitzo.rb +7 -0
- 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,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,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
|