tools-cf-plugin 1.0.0 → 1.1.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.
- data/lib/tools-cf-plugin/plugin.rb +3 -1
- data/lib/tools-cf-plugin/tunnel/base.rb +133 -0
- data/lib/tools-cf-plugin/tunnel/log_entry.rb +46 -0
- data/lib/tools-cf-plugin/tunnel/multi_line_stream.rb +100 -0
- data/lib/tools-cf-plugin/tunnel/stream_location.rb +37 -0
- data/lib/tools-cf-plugin/tunnel/watch.rb +52 -0
- data/lib/tools-cf-plugin/tunnel/watch_logs.rb +122 -0
- data/lib/tools-cf-plugin/version.rb +1 -1
- data/lib/tools-cf-plugin/watch.rb +53 -17
- data/spec/tunnel/base_spec.rb +180 -0
- data/spec/tunnel/log_entry_spec.rb +109 -0
- data/spec/tunnel/multi_line_stream_spec.rb +80 -0
- data/spec/tunnel/stream_location_spec.rb +59 -0
- data/spec/tunnel/watch_logs_spec.rb +117 -0
- data/spec/watch_spec.rb +114 -24
- metadata +62 -8
@@ -0,0 +1,180 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module CFTools::Tunnel
|
4
|
+
describe Base do
|
5
|
+
before do
|
6
|
+
stub(subject).input { { :quiet => true } }
|
7
|
+
end
|
8
|
+
|
9
|
+
describe "#director" do
|
10
|
+
context "when the given director is accessible" do
|
11
|
+
before do
|
12
|
+
stub(subject).address_reachable?("some-director.com", 25555) { true }
|
13
|
+
end
|
14
|
+
|
15
|
+
it "returns the given director" do
|
16
|
+
director = subject.director("some-director.com", nil)
|
17
|
+
expect(director.director_uri.host).to eq("some-director.com")
|
18
|
+
expect(director.director_uri.port).to eq(25555)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
context "when the given director is inaccessible" do
|
23
|
+
before do
|
24
|
+
stub(subject).address_reachable?("some-director.com", 25555) { false }
|
25
|
+
end
|
26
|
+
|
27
|
+
it "opens a tunnel through the gateway" do
|
28
|
+
mock(subject).tunnel_to("some-director.com", 25555, "user@some-gateway") do
|
29
|
+
1234
|
30
|
+
end
|
31
|
+
|
32
|
+
director = subject.director("some-director.com", "user@some-gateway")
|
33
|
+
expect(director.director_uri.host).to eq("127.0.0.1")
|
34
|
+
expect(director.director_uri.port).to eq(1234)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe "#login_to_director" do
|
40
|
+
let(:director) { stub }
|
41
|
+
|
42
|
+
before do
|
43
|
+
stub(director).user = anything
|
44
|
+
stub(director).password = anything
|
45
|
+
stub(director).authenticated? { true }
|
46
|
+
end
|
47
|
+
|
48
|
+
it "assigns the given user/pass on the director" do
|
49
|
+
mock(director).user = "user"
|
50
|
+
mock(director).password = "pass"
|
51
|
+
subject.login_to_director(director, "user", "pass")
|
52
|
+
end
|
53
|
+
|
54
|
+
it "returns true iff director.authenticated?" do
|
55
|
+
mock(director).authenticated? { true }
|
56
|
+
expect(subject.login_to_director(director, "user", "pass")).to be_true
|
57
|
+
end
|
58
|
+
|
59
|
+
it "returns false iff !director.authenticated?" do
|
60
|
+
mock(director).authenticated? { false }
|
61
|
+
expect(subject.login_to_director(director, "user", "pass")).to be_false
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe "#tunnel_to" do
|
66
|
+
let(:gateway) { stub }
|
67
|
+
|
68
|
+
before do
|
69
|
+
stub(gateway).open
|
70
|
+
end
|
71
|
+
|
72
|
+
it "creates a gateway using the given user/host" do
|
73
|
+
mock(Net::SSH::Gateway).new("ghost", "guser") { gateway }
|
74
|
+
subject.tunnel_to("1.2.3.4", 1234, "guser@ghost")
|
75
|
+
end
|
76
|
+
|
77
|
+
it "opens a local tunnel and returns its port" do
|
78
|
+
stub(Net::SSH::Gateway).new("ghost", "guser") { gateway }
|
79
|
+
mock(gateway).open("1.2.3.4", 1234) { 5678 }
|
80
|
+
expect(subject.tunnel_to("1.2.3.4", 1234, "guser@ghost")).to eq(5678)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
describe "#authenticate_with_director" do
|
85
|
+
let(:director) { stub }
|
86
|
+
|
87
|
+
def self.it_asks_interactively
|
88
|
+
it "asks for the credentials interactively" do
|
89
|
+
if saved_credentials
|
90
|
+
mock(subject).login_to_director(director, "user", "pass") { false }.ordered
|
91
|
+
end
|
92
|
+
|
93
|
+
mock_ask("Director Username") { "fizz" }.ordered
|
94
|
+
mock_ask("Director Password", anything) { "buzz" }.ordered
|
95
|
+
|
96
|
+
mock(subject).login_to_director(director, "fizz", "buzz") { true }.ordered
|
97
|
+
|
98
|
+
subject.authenticate_with_director(director, "foo", saved_credentials)
|
99
|
+
end
|
100
|
+
|
101
|
+
context "when the interactive user/pass is valid" do
|
102
|
+
it "returns true" do
|
103
|
+
if saved_credentials
|
104
|
+
mock(subject).login_to_director(director, "user", "pass") { false }.ordered
|
105
|
+
end
|
106
|
+
|
107
|
+
mock_ask("Director Username") { "fizz" }.ordered
|
108
|
+
mock_ask("Director Password", anything) { "buzz" }.ordered
|
109
|
+
|
110
|
+
mock(subject).login_to_director(director, "fizz", "buzz") { true }.ordered
|
111
|
+
|
112
|
+
expect(
|
113
|
+
subject.authenticate_with_director(director, "foo", saved_credentials)
|
114
|
+
).to be_true
|
115
|
+
end
|
116
|
+
|
117
|
+
it "saves them to the bosh config" do
|
118
|
+
if saved_credentials
|
119
|
+
mock(subject).login_to_director(director, "user", "pass") { false }.ordered
|
120
|
+
end
|
121
|
+
|
122
|
+
mock_ask("Director Username") { "fizz" }.ordered
|
123
|
+
mock_ask("Director Password", anything) { "buzz" }.ordered
|
124
|
+
|
125
|
+
mock(subject).login_to_director(director, "fizz", "buzz") { true }.ordered
|
126
|
+
|
127
|
+
mock(subject).save_auth("foo", "username" => "fizz", "password" => "buzz")
|
128
|
+
|
129
|
+
subject.authenticate_with_director(director, "foo", saved_credentials)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
context "when the interactive user/pass is invalid" do
|
134
|
+
it "asks again" do
|
135
|
+
if saved_credentials
|
136
|
+
mock(subject).login_to_director(director, "user", "pass") { false }.ordered
|
137
|
+
end
|
138
|
+
|
139
|
+
mock_ask("Director Username") { "fizz" }.ordered
|
140
|
+
mock_ask("Director Password", anything) { "buzz" }.ordered
|
141
|
+
|
142
|
+
mock(subject).login_to_director(director, "fizz", "buzz") { false }.ordered
|
143
|
+
|
144
|
+
mock_ask("Director Username") { "a" }.ordered
|
145
|
+
mock_ask("Director Password", anything) { "b" }.ordered
|
146
|
+
|
147
|
+
mock(subject).login_to_director(director, "a", "b") { true }.ordered
|
148
|
+
|
149
|
+
subject.authenticate_with_director(director, "foo", saved_credentials)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
context "when saved credentials are given" do
|
155
|
+
let(:saved_credentials) { { "username" => "user", "password" => "pass" } }
|
156
|
+
|
157
|
+
context "and they are valid" do
|
158
|
+
it "returns true" do
|
159
|
+
mock(subject).login_to_director(director, "user", "pass") { true }
|
160
|
+
|
161
|
+
expect(
|
162
|
+
subject.authenticate_with_director(
|
163
|
+
director, "foo", "username" => "user", "password" => "pass")
|
164
|
+
).to be_true
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
context "and they are NOT valid" do
|
169
|
+
it_asks_interactively
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
context "when auth credentials are NOT given" do
|
174
|
+
let(:saved_credentials) { nil }
|
175
|
+
|
176
|
+
it_asks_interactively
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe CFTools::Tunnel::LogEntry do
|
4
|
+
let(:label) { "some-label" }
|
5
|
+
let(:line) { "something happened!" }
|
6
|
+
let(:stream) { :stdout }
|
7
|
+
|
8
|
+
subject { described_class.new(label, line, stream) }
|
9
|
+
|
10
|
+
describe "#label" do
|
11
|
+
let(:label) { "some-label" }
|
12
|
+
|
13
|
+
it "returns the label of the entry" do
|
14
|
+
expect(subject.label).to eq("some-label")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe "#line" do
|
19
|
+
let(:line) { "something happened!" }
|
20
|
+
|
21
|
+
it "returns the line of the entry" do
|
22
|
+
expect(subject.line).to eq("something happened!")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
describe "#stream" do
|
27
|
+
let(:stream) { :stdout }
|
28
|
+
|
29
|
+
it "returns the stream of the entry" do
|
30
|
+
expect(subject.stream).to eq(:stdout)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe "#message" do
|
35
|
+
context "when the line is JSON" do
|
36
|
+
let(:line) { '{"message":"foo"}' }
|
37
|
+
|
38
|
+
it "return its 'message' field" do
|
39
|
+
expect(subject.message).to eq("foo")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context "when the line is NOT JSON" do
|
44
|
+
let(:line) { "bar" }
|
45
|
+
|
46
|
+
it "returns the line" do
|
47
|
+
expect(subject.message).to eq("bar")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#log_level" do
|
53
|
+
context "when the line is JSON" do
|
54
|
+
let(:line) { '{"log_level":"debug"}' }
|
55
|
+
|
56
|
+
it "return its 'log_level' field" do
|
57
|
+
expect(subject.log_level).to eq("debug")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
context "when the line is NOT JSON" do
|
62
|
+
let(:line) { "bar" }
|
63
|
+
|
64
|
+
it "returns nil" do
|
65
|
+
expect(subject.log_level).to be_nil
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
describe "#timestamp" do
|
71
|
+
let(:time) { Time.now }
|
72
|
+
|
73
|
+
around { |example| Timecop.freeze(&example) }
|
74
|
+
|
75
|
+
context "when the line is JSON" do
|
76
|
+
context "and the timestamp is a string" do
|
77
|
+
let(:line) { %Q[{"timestamp":"#{time.strftime("%F %T")}"}] }
|
78
|
+
|
79
|
+
it "return its parsed 'timestamp' field" do
|
80
|
+
expect(subject.timestamp.to_s).to eq(time.to_s)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
context "and the timestamp is numeric" do
|
85
|
+
let(:line) { %Q[{"timestamp":#{time.to_f}}] }
|
86
|
+
|
87
|
+
it "interprets it as time since UNIX epoch" do
|
88
|
+
expect(subject.timestamp.to_s).to eq(time.to_s)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
context "and the timestamp is missing" do
|
93
|
+
let(:line) { %Q[{}] }
|
94
|
+
|
95
|
+
it "returns the time at which the entry was created" do
|
96
|
+
expect(subject.timestamp).to eq(time)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
context "when the line is NOT JSON" do
|
102
|
+
let(:line) { "bar" }
|
103
|
+
|
104
|
+
it "returns the time at which the entry was created" do
|
105
|
+
expect(subject.timestamp).to eq(time)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module CFTools::Tunnel
|
4
|
+
describe MultiLineStream do
|
5
|
+
let(:director) { stub }
|
6
|
+
let(:deployment) { "some-deployment" }
|
7
|
+
let(:gateway_user) { "vcap" }
|
8
|
+
let(:gateway_host) { "vcap.me" }
|
9
|
+
|
10
|
+
let(:gateway) { stub }
|
11
|
+
let(:entries) { Queue.new }
|
12
|
+
|
13
|
+
subject do
|
14
|
+
described_class.new(director, deployment, gateway_user, gateway_host)
|
15
|
+
end
|
16
|
+
|
17
|
+
before do
|
18
|
+
stub(subject).create_ssh_user { ["1.2.3.4", "some_user_1"] }
|
19
|
+
stub(subject).gateway { gateway }
|
20
|
+
stub(subject).entry_queue { entries }
|
21
|
+
stub(Thread).new { |blk| blk.call }
|
22
|
+
end
|
23
|
+
|
24
|
+
describe "#stream" do
|
25
|
+
it "yields entries as they come through the queue" do
|
26
|
+
entries << :a
|
27
|
+
entries << :b
|
28
|
+
entries << nil
|
29
|
+
|
30
|
+
seen = []
|
31
|
+
subject.stream({}) do |e|
|
32
|
+
seen << e
|
33
|
+
end
|
34
|
+
|
35
|
+
expect(seen).to eq([:a, :b])
|
36
|
+
end
|
37
|
+
|
38
|
+
it "spawns a SSH tunnel for each location" do
|
39
|
+
mock(Thread).new { |blk| blk.call }.ordered
|
40
|
+
mock(Thread).new { |blk| blk.call }.ordered
|
41
|
+
|
42
|
+
mock(subject).create_ssh_user("foo", 0, entries) { ["1.2.3.4", "some_user_1"] }
|
43
|
+
mock(subject).create_ssh_user("bar", 0, entries) { ["1.2.3.5", "some_user_2"] }
|
44
|
+
|
45
|
+
mock(gateway).ssh("1.2.3.4", "some_user_1")
|
46
|
+
mock(gateway).ssh("1.2.3.5", "some_user_2")
|
47
|
+
|
48
|
+
entries << nil
|
49
|
+
|
50
|
+
subject.stream(["foo", 0] => [], ["bar", 0]=> [])
|
51
|
+
end
|
52
|
+
|
53
|
+
it "streams from each location" do
|
54
|
+
ssh = stub
|
55
|
+
|
56
|
+
locations = {
|
57
|
+
"1.2.3.4" => [
|
58
|
+
StreamLocation.new("some/path", "some-label"),
|
59
|
+
StreamLocation.new("some/other/path", "some-label")
|
60
|
+
],
|
61
|
+
"1.2.3.5" => [
|
62
|
+
StreamLocation.new("some/path", "other-label"),
|
63
|
+
]
|
64
|
+
}
|
65
|
+
|
66
|
+
locations.each do |_, locs|
|
67
|
+
locs.each do |loc|
|
68
|
+
mock(loc).stream_lines(ssh)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
stub(gateway).ssh { |_, _, blk| blk.call(ssh) }
|
73
|
+
|
74
|
+
entries << nil
|
75
|
+
|
76
|
+
subject.stream(locations)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe CFTools::Tunnel::StreamLocation do
|
4
|
+
let(:path) { "some/file" }
|
5
|
+
let(:label) { "some_component/0" }
|
6
|
+
|
7
|
+
subject { described_class.new(path, label) }
|
8
|
+
|
9
|
+
describe "#path" do
|
10
|
+
let(:path) { "some/path" }
|
11
|
+
|
12
|
+
it "returns the path of the entry" do
|
13
|
+
expect(subject.path).to eq("some/path")
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe "#label" do
|
18
|
+
let(:label) { "some-label" }
|
19
|
+
|
20
|
+
it "returns the label of the entry" do
|
21
|
+
expect(subject.label).to eq("some-label")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe "#stream_lines" do
|
26
|
+
let(:ssh) { stub }
|
27
|
+
|
28
|
+
it "tails the file under /var/vcap/sys/log" do
|
29
|
+
mock(ssh).exec("tail -f /var/vcap/sys/log/#{path}")
|
30
|
+
subject.stream_lines(ssh)
|
31
|
+
end
|
32
|
+
|
33
|
+
it "yields log entries as lines come through the channel" do
|
34
|
+
stub(ssh).exec { |_, blk| blk.call({}, :stdout, "foo\nbar\n") }
|
35
|
+
|
36
|
+
lines = []
|
37
|
+
subject.stream_lines(ssh) do |entry|
|
38
|
+
lines << entry.message
|
39
|
+
end
|
40
|
+
|
41
|
+
expect(lines).to eq(["foo\n", "bar\n"])
|
42
|
+
end
|
43
|
+
|
44
|
+
it "merges chunks that form a complete line" do
|
45
|
+
channel = {}
|
46
|
+
stub(ssh).exec do |_, blk|
|
47
|
+
blk.call(channel, :stdout, "fo")
|
48
|
+
blk.call(channel, :stdout, "o\nbar\n")
|
49
|
+
end
|
50
|
+
|
51
|
+
lines = []
|
52
|
+
subject.stream_lines(ssh) do |entry|
|
53
|
+
lines << entry.message
|
54
|
+
end
|
55
|
+
|
56
|
+
expect(lines).to eq(["foo\n", "bar\n"])
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module CFTools::Tunnel
|
4
|
+
describe WatchLogs do
|
5
|
+
let(:director_uri) { "https://some-director.com:25555" }
|
6
|
+
|
7
|
+
let(:director) { Bosh::Cli::Director.new(director_uri) }
|
8
|
+
|
9
|
+
let(:stream) { stub }
|
10
|
+
|
11
|
+
let(:vms) do
|
12
|
+
[ { "ips" => ["1.2.3.4"], "job_name" => "cloud_controller", "index" => 0 },
|
13
|
+
{ "ips" => ["1.2.3.5"], "job_name" => "dea_next", "index" => 0 },
|
14
|
+
{ "ips" => ["1.2.3.6"], "job_name" => "dea_next", "index" => 1 }
|
15
|
+
]
|
16
|
+
end
|
17
|
+
|
18
|
+
let(:deployments) do
|
19
|
+
[{ "name" => "some-deployment", "releases" => [{ "name" => "cf-release" }] }]
|
20
|
+
end
|
21
|
+
|
22
|
+
def mock_cli
|
23
|
+
any_instance_of(described_class) do |cli|
|
24
|
+
mock(cli)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def stub_cli
|
29
|
+
any_instance_of(described_class) do |cli|
|
30
|
+
stub(cli)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
before do
|
35
|
+
stub(director).list_deployments { deployments }
|
36
|
+
stub(director).fetch_vm_state { vms }
|
37
|
+
stub_cli.connected_director { director }
|
38
|
+
|
39
|
+
stub(stream).stream
|
40
|
+
stub_cli.stream_for { stream }
|
41
|
+
end
|
42
|
+
|
43
|
+
it "connects to the given director" do
|
44
|
+
mock_cli.connected_director(
|
45
|
+
"some-director.com", "someuser@somehost.com") do
|
46
|
+
director
|
47
|
+
end
|
48
|
+
|
49
|
+
cf %W[watch-logs some-director.com --gateway someuser@somehost.com]
|
50
|
+
end
|
51
|
+
|
52
|
+
context "when no gateway user/host is specified" do
|
53
|
+
it "defaults to vcap@director" do
|
54
|
+
mock_cli.connected_director(
|
55
|
+
"some-director.com", "vcap@some-director.com") do
|
56
|
+
director
|
57
|
+
end
|
58
|
+
|
59
|
+
cf %W[watch-logs some-director.com]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context "when there are no jobs to log" do
|
64
|
+
let(:vms) { [] }
|
65
|
+
|
66
|
+
it "fails with a message" do
|
67
|
+
cf %W[watch-logs some-director.com]
|
68
|
+
expect(error_output).to say("No locations found.")
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
context "when there are jobs to log" do
|
73
|
+
it "streams their locations" do
|
74
|
+
mock(stream).stream(anything) do |locations, blk|
|
75
|
+
expect(locations).to include(["cloud_controller", 0])
|
76
|
+
expect(locations).to include(["dea_next", 0])
|
77
|
+
expect(locations).to include(["dea_next", 1])
|
78
|
+
end
|
79
|
+
|
80
|
+
cf %W[watch-logs some-director.com]
|
81
|
+
end
|
82
|
+
|
83
|
+
it "pretty-prints their log entries" do
|
84
|
+
entry1_time = Time.new(2011, 06, 21, 1, 2, 3)
|
85
|
+
entry2_time = Time.new(2011, 06, 21, 1, 2, 4)
|
86
|
+
entry3_time = Time.new(2011, 06, 21, 1, 2, 5)
|
87
|
+
|
88
|
+
entry1 = LogEntry.new(
|
89
|
+
"cloud_controller/0",
|
90
|
+
%Q[{"message":"a","timestamp":#{entry1_time.to_f},"log_level":"info"}],
|
91
|
+
:stdout)
|
92
|
+
|
93
|
+
entry2 = LogEntry.new(
|
94
|
+
"dea_next/1",
|
95
|
+
%Q[{"message":"b","timestamp":#{entry2_time.to_f},"log_level":"warn"}],
|
96
|
+
:stdout)
|
97
|
+
|
98
|
+
entry3 = LogEntry.new(
|
99
|
+
"dea_next/0",
|
100
|
+
%Q[{"message":"c","timestamp":#{entry3_time.to_f},"log_level":"error"}],
|
101
|
+
:stdout)
|
102
|
+
|
103
|
+
mock(stream).stream(anything) do |locations, blk|
|
104
|
+
blk.call(entry1)
|
105
|
+
blk.call(entry2)
|
106
|
+
blk.call(entry3)
|
107
|
+
end
|
108
|
+
|
109
|
+
cf %W[watch-logs some-director.com]
|
110
|
+
|
111
|
+
expect(output).to say("cloud_controller/0 01:02:03 AM info a\n")
|
112
|
+
expect(output).to say("dea_next/1 01:02:04 AM warn b\n")
|
113
|
+
expect(output).to say("dea_next/0 01:02:05 AM error c\n")
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|