tools-cf-plugin 1.0.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|