izanami 0.14.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,195 @@
1
+ require_relative '../helper'
2
+ require 'izanami/app'
3
+
4
+ describe Izanami::App do
5
+ include Rack::Test::Methods
6
+
7
+ def app
8
+ Izanami::App
9
+ end
10
+
11
+ def worker
12
+ fork { yield }
13
+ end
14
+
15
+ # Simulate Worker::Command
16
+ def publish(redis_url, id)
17
+ redis = Redis.new(url: redis_url)
18
+
19
+ # wait for a subscriber to connect to a channel
20
+ loop do
21
+ subscribers = redis.info['pubsub_channels']
22
+ break if subscribers != '0'
23
+ end
24
+
25
+ key = "izanami:commands:#{id}"
26
+ channel = "izanami:commands:#{id}:channel"
27
+ output = 'PONG'
28
+ status = 'success'
29
+
30
+ redis.publish channel, output
31
+ redis.multi do
32
+ redis.publish channel, '--EOC--'
33
+ redis.hset key, 'status', status
34
+ redis.hset key, 'output', output
35
+ end
36
+ end
37
+
38
+ def save_commands(redis, commands)
39
+ commands.each do |command|
40
+ # set all the values in redis in one step
41
+ redis.hmset "izanami:commands:#{command['id']}", *command.to_a.flatten
42
+ redis.sadd "izanami:commands:ids", command['id']
43
+ end
44
+ end
45
+
46
+ let!(:redis_url) { ENV['IZANAMI_REDIS_URL'] }
47
+ let(:redis) { Redis.new(url: redis_url) }
48
+
49
+ let(:command_a) do
50
+ {
51
+ 'tasks' => 'ping',
52
+ 'id' => '20130816101418',
53
+ 'output' => 'PONG',
54
+ 'status' => 'success',
55
+ }
56
+ end
57
+ let(:command_b) do
58
+ {
59
+ 'tasks' => 'ping2',
60
+ 'id' => '20130815121400',
61
+ 'output' => 'PONG2',
62
+ 'status' => 'fail',
63
+ }
64
+ end
65
+
66
+ let(:commands) { [command_a, command_b] }
67
+ let(:user) { ENV['IZANAMI_USER'] }
68
+ let(:password) { ENV['IZANAMI_PASSWORD'] }
69
+
70
+ before { authorize(user, password) }
71
+
72
+ after { redis.flushdb }
73
+
74
+ describe 'GET /' do
75
+ context 'when there are no command logs' do
76
+ before { redis.flushdb }
77
+
78
+ it 'shows the homepage' do
79
+ get '/'
80
+
81
+ last_response.should be_ok
82
+ last_response.body.should include('Command history')
83
+ end
84
+ end
85
+
86
+ context 'when there are command logs' do
87
+ before { save_commands(redis, commands) }
88
+
89
+ it 'shows the homepage with the commands' do
90
+ get '/'
91
+
92
+ last_response.should be_ok
93
+ last_response.body.should include('Command history')
94
+ commands.each do |command|
95
+ last_response.body.should include(command['id'])
96
+ last_response.body.should include(command['tasks'])
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ describe 'GET /commands' do
103
+ it 'redirects to home page' do
104
+ get '/commands'
105
+
106
+ last_response.should be_redirect
107
+ last_response.original_headers['Location'].should == 'http://example.org/'
108
+ end
109
+ end
110
+
111
+ describe 'DELETE /commands' do
112
+ before { save_commands(redis, commands) }
113
+
114
+ it 'removes all commands' do
115
+ delete '/commands'
116
+ last_response.should be_redirect
117
+
118
+ follow_redirect!
119
+
120
+ last_response.should be_ok
121
+ last_response.body.should include('Command history')
122
+ commands.each do |command|
123
+ last_response.body.should_not include(command['id'])
124
+ last_response.body.should_not include(command['tasks'])
125
+ end
126
+ end
127
+ end
128
+
129
+ describe 'POST /commands' do
130
+ before do
131
+ # we don't want to `defer` any worker here.
132
+ Process.should_receive(:fork).and_return(123)
133
+ end
134
+
135
+ it 'performs the commands' do
136
+ post '/commands', 'tasks' => 'ping'
137
+ last_response.should be_redirect
138
+
139
+ follow_redirect!
140
+ last_response.should be_ok
141
+ last_response.body.should include('ping')
142
+ end
143
+ end
144
+
145
+ describe 'GET /commands/:id' do
146
+ let(:id) { command_a['id'] }
147
+ let(:tasks) { command_a['tasks'] }
148
+
149
+ before { save_commands(redis, commands) }
150
+
151
+ it 'shows the command' do
152
+ get "/commands/#{id}"
153
+
154
+ last_response.should be_ok
155
+ last_response.body.should include(tasks)
156
+ end
157
+ end
158
+
159
+ describe 'GET /commands/:id/log' do
160
+ context 'when the output is already saved' do
161
+ let(:id) { command_a['id'] }
162
+ let(:output) { command_a['output'] }
163
+
164
+ before { save_commands(redis, commands) }
165
+
166
+ it 'gets the log as an event-stream' do
167
+ get "/commands/#{id}/log"
168
+
169
+ last_response.should be_ok
170
+ last_response.content_type.should include('text/event-stream')
171
+ last_response.body.should include("data: #{output}\n\n")
172
+ last_response.body.should include("data: CLOSE\n\n")
173
+ end
174
+ end
175
+
176
+ context 'when the output is been generated' do
177
+ let(:command) { { 'id' => '20130716150200', 'tasks' => 'ping' } }
178
+ let(:id) { command['id'] }
179
+
180
+ before { save_commands(redis, [command]) }
181
+ after { Process.wait(@pid) if @pid }
182
+
183
+ it 'gets the log as an event-stream' do
184
+ @pid = worker { publish(redis_url, id) }
185
+
186
+ get "/commands/#{id}/log"
187
+
188
+ last_response.should be_ok
189
+ last_response.content_type.should include('text/event-stream')
190
+ last_response.body.should include("data: PONG\n\n")
191
+ last_response.body.should include("data: CLOSE\n\n")
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,38 @@
1
+ require_relative '../../helper'
2
+ require 'izanami/channel'
3
+
4
+ describe Izanami::Channel::Input do
5
+ let(:mapper) { double('mapper') }
6
+ let(:id) { '123' }
7
+
8
+ subject { Izanami::Channel::Input.new(mapper, id) }
9
+
10
+ describe '#<<' do
11
+ let(:payload_a) { 'ping' }
12
+ let(:payload_b) { 'pong' }
13
+
14
+ before do
15
+ mapper.should_receive(:publish).with(id, payload_a)
16
+ mapper.should_receive(:publish).with(id, payload_b)
17
+ end
18
+
19
+ it 'publishes the content and keeps it' do
20
+ subject << payload_a
21
+ subject << payload_b
22
+
23
+ subject.to_s.
24
+ should == "#{payload_a}#{Izanami::Channel::SEPARATOR}#{payload_b}"
25
+ end
26
+ end
27
+
28
+ describe '#close' do
29
+ before do
30
+ mapper.should_receive(:update).with(id, 'output', '')
31
+ mapper.should_receive(:publish).with(id, Izanami::Channel::EOC)
32
+ end
33
+
34
+ it 'closes the channel, stores and returns the content' do
35
+ subject.close.should == ''
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,66 @@
1
+ require_relative '../helper'
2
+ require 'izanami/channel'
3
+
4
+ module Mock
5
+ class On < Struct.new(:buffer)
6
+ def message
7
+ buffer.each do |output|
8
+ yield 'channel-id', output
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ shared_examples 'Channel#read' do
15
+ it 'reads the command output' do
16
+ output = []
17
+ subject.read(id) { |line| output << line }
18
+
19
+ output.should == expected
20
+ end
21
+ end
22
+
23
+ describe Izanami::Channel do
24
+ let(:mapper) { double('mapper') }
25
+ let(:id) { '123' }
26
+
27
+ subject { Izanami::Channel.new(mapper) }
28
+
29
+ describe '#write' do
30
+ it 'initiates a channel output instance' do
31
+ subject.write(id).should be_instance_of(Izanami::Channel::Input)
32
+ end
33
+ end
34
+
35
+ describe '#read' do
36
+ let(:expected) { %w[ping pong] }
37
+
38
+ context 'when the command has an output' do
39
+ let(:command) do
40
+ {
41
+ 'id' => id,
42
+ 'output' => "ping#{Izanami::Channel::SEPARATOR}pong"
43
+ }
44
+ end
45
+
46
+ before do
47
+ mapper.should_receive(:find).with(id).and_return(command)
48
+ end
49
+
50
+ it_should_behave_like 'Channel#read'
51
+ end
52
+
53
+ context 'when the command is been executed' do
54
+ let(:command) { { 'id' => id, } }
55
+ let(:on) { Mock::On.new(%W[ping pong #{Izanami::Channel::EOC}]) }
56
+
57
+ before do
58
+ mapper.should_receive(:find).with(id).and_return(command)
59
+ mapper.should_receive(:subscribe).with(id).and_yield(on)
60
+ mapper.should_receive(:unsubscribe).with(id)
61
+ end
62
+
63
+ it_should_behave_like 'Channel#read'
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,48 @@
1
+ require_relative '../helper'
2
+ require 'izanami/mapper'
3
+
4
+ describe Izanami::Mapper do
5
+ let(:redis) { double('redis') }
6
+ let(:options) { { redis: redis } }
7
+
8
+ subject { Izanami::Mapper.new(options) }
9
+
10
+ describe '#redis' do
11
+ context 'with redis instance' do
12
+ it 'uses the given redis instance' do
13
+ subject.redis.should == redis
14
+ end
15
+ end
16
+
17
+ context 'with redis options' do
18
+ let(:redis_url) { ENV['IZANAMI_REDIS_URL'] }
19
+ let(:options) { { url: redis_url } }
20
+
21
+ it 'initializes a new redis instance' do
22
+ subject.redis.should be_instance_of(Redis)
23
+ end
24
+ end
25
+ end
26
+
27
+ describe '#namespace' do
28
+ context 'with a namespace' do
29
+ let(:options) { { namespace: 'spec' } }
30
+
31
+ it 'uses that namespace' do
32
+ subject.namespace.should == 'spec'
33
+ end
34
+ end
35
+
36
+ context 'without a namespace' do
37
+ it 'uses the default namespace' do
38
+ subject.namespace.should == 'izanami'
39
+ end
40
+ end
41
+ end
42
+
43
+ describe '#client' do
44
+ it 'uses redis namespace as a proxy' do
45
+ subject.client.should be_instance_of(Redis::Namespace)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,154 @@
1
+ require_relative '../../helper'
2
+ require 'izanami/mappers/command'
3
+
4
+ describe Izanami::Mappers do
5
+ let(:redis_url) { ENV['IZANAMI_REDIS_URL'] }
6
+ let(:redis) { Redis.new(url: redis_url) }
7
+
8
+ let(:options) { { redis: redis } }
9
+
10
+ let(:commands) do
11
+ [
12
+ { 'id' => '20130804160130', 'tasks' => 'ping', },
13
+ { 'id' => '20130804150130', 'tasks' => 'check', },
14
+ { 'id' => '20130804140130', 'tasks' => 'deploy', },
15
+ { 'id' => '20130803160130', 'tasks' => 'reboot', },
16
+ { 'id' => '20130802160130', 'tasks' => 'ping', },
17
+ ]
18
+ end
19
+
20
+ subject { Izanami::Mappers::Command.new(options) }
21
+
22
+ describe '#namespace' do
23
+ context 'with a namespace' do
24
+ let(:options) { { redis: redis, namespace: 'spec' } }
25
+
26
+ it 'uses that namespace' do
27
+ subject.namespace.should == 'spec:commands'
28
+ end
29
+ end
30
+
31
+ context 'without a namespace' do
32
+ it 'uses the default namespace' do
33
+ subject.namespace.should == 'izanami:commands'
34
+ end
35
+ end
36
+ end
37
+
38
+ describe '#ttl' do
39
+ context 'with a ttl option' do
40
+ let(:options) { { redis: redis, ttl: 1 } }
41
+
42
+ it 'uses that ttl' do
43
+ subject.ttl.should == 1
44
+ end
45
+ end
46
+
47
+ context 'without a ttl option' do
48
+ let(:options) { { redis: redis } }
49
+
50
+ it 'uses the default ttl' do
51
+ subject.ttl.should == 604800
52
+ end
53
+ end
54
+ end
55
+
56
+ context 'redis interaction' do
57
+ before do
58
+ commands.each do |command|
59
+ subject.save command
60
+ end
61
+ end
62
+
63
+ after { redis.flushdb }
64
+
65
+ describe '#find' do
66
+ let(:command) { { 'id' => '1234', 'tasks' => 'test' } }
67
+
68
+ context 'when the command exists' do
69
+ before { subject.save(command) }
70
+
71
+ it 'retrieves the content' do
72
+ subject.find(command['id']).should == command
73
+ end
74
+ end
75
+
76
+ context "when the command doesn't exist" do
77
+ it 'returns nil' do
78
+ subject.find(command['id']).should be_nil
79
+ end
80
+ end
81
+ end
82
+
83
+ describe '#take' do
84
+ it 'takes the elements sorted by their ids' do
85
+ subject.take(3).should == commands.take(3)
86
+ end
87
+ end
88
+
89
+ describe '#count' do
90
+ it 'counts all the records' do
91
+ subject.count.should == commands.count
92
+ end
93
+ end
94
+
95
+ describe '#delete_all' do
96
+ it 'deletes all the commands stored in redis' do
97
+ subject.delete_all
98
+
99
+ subject.count.should == 0
100
+ commands.each do |command|
101
+ subject.exists?(command['id']).should be_false
102
+ end
103
+ end
104
+ end
105
+
106
+ describe '#delete' do
107
+ let(:ids) { commands.take(2).map { |c| c['id'] } }
108
+
109
+ it 'deletes the ids stored in redis' do
110
+ subject.delete(ids)
111
+
112
+ ids.each do |id|
113
+ subject.exists?(id).should be_false
114
+ end
115
+ end
116
+ end
117
+
118
+ describe '#save' do
119
+ context 'with an id' do
120
+ let(:command) { { 'id' => '123', 'tasks' => 'ping' } }
121
+
122
+ it 'saves the record with the given key' do
123
+ subject.save(command)
124
+
125
+ subject.find(command['id']).should == command
126
+ end
127
+ end
128
+
129
+ context 'without an id' do
130
+ let(:command) { { 'tasks' => 'ping' } }
131
+
132
+ it 'saves the record with a new key' do
133
+ hash = subject.save(command)
134
+
135
+ hash['id'].should_not be_nil
136
+ subject.find(hash['id'])['tasks'].should == command['tasks']
137
+ end
138
+ end
139
+ end
140
+
141
+ describe '#update' do
142
+ let(:command) { { 'id' => '123', 'tasks' => 'ping' } }
143
+
144
+ before { subject.save(command) }
145
+
146
+ it 'updates the record with the given key' do
147
+ subject.update('123', 'tasks', 'pong')
148
+
149
+ subject.find('123').should_not == command
150
+ subject.find('123').should == { 'id' => '123', 'tasks' => 'pong' }
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,122 @@
1
+ require_relative '../../helper'
2
+ require 'izanami/workers/command'
3
+ require 'izanami/mappers/command'
4
+
5
+ shared_context 'mapper' do
6
+ let(:mapper) { Izanami::Mappers::Command.new(redis_options) }
7
+
8
+ before { mapper.client.flushdb }
9
+ after { mapper.client.flushdb }
10
+ end
11
+
12
+ shared_examples '#handle_streams' do
13
+ include_context 'mapper'
14
+
15
+ let(:stdin) { StringIO.new }
16
+ let(:stdout) { StringIO.new(output) }
17
+ let(:thread) { Thread.new { double('status', success?: status) } }
18
+
19
+ it 'handles the shell command IO objects and stores the result' do
20
+ subject.handle_streams(stdin, stdout, thread)
21
+
22
+ c = mapper.find(command['id'])
23
+ c['output'].should == output
24
+ c['status'].should == status_string
25
+ end
26
+ end
27
+
28
+ describe Izanami::Workers::Command do
29
+ describe 'worker class methods' do
30
+ subject { Izanami::Workers::Command }
31
+
32
+ describe '.defer' do
33
+ it { should respond_to(:defer) }
34
+ end
35
+
36
+ describe '.call' do
37
+ it { should respond_to(:call) }
38
+ end
39
+
40
+ describe '.run' do
41
+ it { should respond_to(:run) }
42
+ end
43
+ end
44
+
45
+ describe 'worker instance methods' do
46
+ let(:command) { { 'id' => '123', 'tasks' => 'ping' } }
47
+ let(:redis_options) { { url: ENV['IZANAMI_REDIS_URL'] } }
48
+ subject { Izanami::Workers::Command.new(redis_options, command) }
49
+
50
+ describe '#defer' do
51
+ it { should respond_to(:defer) }
52
+
53
+ context 'calling #defer' do
54
+ let(:pid) { 123 }
55
+
56
+ it 'forks the process to detach the execution' do
57
+ Process.should_receive(:fork).and_return(pid)
58
+ Process.should_receive(:detach).with(pid)
59
+ subject.defer.should == pid
60
+ end
61
+ end
62
+ end
63
+
64
+ describe '#call' do
65
+ it { should respond_to(:call) }
66
+
67
+ context 'calling #call' do
68
+ include_context 'mapper'
69
+
70
+ it 'executes the `cap` command' do
71
+ subject.call
72
+
73
+ c = mapper.find(command['id'])
74
+ c['output'].should include('PONG')
75
+ end
76
+ end
77
+ end
78
+
79
+ describe '#run' do
80
+ it { should respond_to(:run) }
81
+ end
82
+
83
+ describe '#shell_env' do
84
+ before { ENV['BUNDLE_IZANAMI_STUB'] = 'spec' }
85
+
86
+ it 'cleans all bundler environment variables' do
87
+ subject.shell_env['BUNDLE_IZANAMI_STUB'].should be_nil
88
+ end
89
+ end
90
+
91
+ describe '#shell_options' do
92
+ it 'changes the dir for with the sandbox' do
93
+ subject.shell_options[:chdir].should == ENV['IZANAMI_SANDBOX']
94
+ end
95
+ end
96
+
97
+ describe '#cmd' do
98
+ it 'calls the `cap` command using `bundler`' do
99
+ subject.cmd.should == "bundle exec cap #{command['tasks']}"
100
+ end
101
+ end
102
+
103
+ describe '#handle_streams' do
104
+
105
+ context 'with successful status' do
106
+ let(:output) { " * 2013-08-14 15:47:06 executing `ping'`\nPONG" }
107
+ let(:status) { true }
108
+ let(:status_string) { 'success' }
109
+
110
+ it_should_behave_like '#handle_streams'
111
+ end
112
+
113
+ context 'with failed status' do
114
+ let(:output) { "the task `ping' does not exist" }
115
+ let(:status) { false }
116
+ let(:status_string) { 'fail' }
117
+
118
+ it_should_behave_like '#handle_streams'
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,4 @@
1
+
2
+ task :ping do
3
+ $stdout.puts 'PONG'
4
+ end
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'capistrano'
@@ -0,0 +1,12 @@
1
+ <div class="panel panel-<%= type %>">
2
+ <div class="panel-heading"><%= title %></div>
3
+
4
+ <ul class="list-group">
5
+ <% commands.each do |command| %>
6
+ <li class="list-group-item">
7
+ <% time = id_to_time(command['id']) %>
8
+ <abbr title="<%= time %>" class="initialism"><%= abbr_time(time) %></abbr> <a href="<%= "/commands/#{command['id']}" %>"><%= command['tasks'] %></a>
9
+ </li>
10
+ <% end %>
11
+ </ul>
12
+ </div>
data/views/command.erb ADDED
@@ -0,0 +1,10 @@
1
+ <div class="row">
2
+ <div class="col-lg-12">
3
+ <h2><%= command['tasks'] %> <span class="label label-<%= label_status(command['status']) %>"><%= format_status(command['status']) %></span></h2>
4
+ <p>Command started at <%= id_to_time(command['id']) %></p>
5
+ </div>
6
+ </div>
7
+
8
+ <div class="row">
9
+ <pre id="log" data-id="<%= command['id'] %>" class="col-lg-12"></pre>
10
+ </div>