izanami 0.14.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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>