tl1 0.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.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rubocop.yml +26 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +675 -0
- data/README.md +188 -0
- data/Rakefile +7 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/tl1.rb +11 -0
- data/lib/tl1/ast.rb +268 -0
- data/lib/tl1/command.rb +113 -0
- data/lib/tl1/input_format.rb +23 -0
- data/lib/tl1/output_format.rb +23 -0
- data/lib/tl1/platforms/bti.rb +129 -0
- data/lib/tl1/session.rb +86 -0
- data/lib/tl1/test_io.rb +31 -0
- data/lib/tl1/version.rb +4 -0
- data/tl1.gemspec +26 -0
- metadata +106 -0
data/README.md
ADDED
@@ -0,0 +1,188 @@
|
|
1
|
+
# Tl1
|
2
|
+
|
3
|
+
The TL1 protocol is used by network operators to manage optical and other
|
4
|
+
networking equipment produced by multiple vendors. Although it is has a terse
|
5
|
+
and unusual command syntax, it was created with the intent of being useful for
|
6
|
+
interactive, command-line-like use.
|
7
|
+
|
8
|
+
This library offers a small set of utility classes intended for automating TL1
|
9
|
+
sessions. It was initially created in 2017 to interact with BTI 7200 devices,
|
10
|
+
but it aspires to be useful for anything that speaks TL1.
|
11
|
+
|
12
|
+
`tl1` is tested on Ruby 2.3 and 2.4 but probably works on anything newer than
|
13
|
+
2.0. It has no other formal dependencies, but to make it useful, you probably
|
14
|
+
need `Net::SSH::Telnet` (https://github.com/duke-automation/net-ssh-telnet), see
|
15
|
+
Connecting below.
|
16
|
+
|
17
|
+
## Installation
|
18
|
+
|
19
|
+
Add this line to your application's Gemfile:
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
gem 'tl1'
|
23
|
+
```
|
24
|
+
|
25
|
+
And then execute:
|
26
|
+
|
27
|
+
$ bundle
|
28
|
+
|
29
|
+
Or install it yourself as:
|
30
|
+
|
31
|
+
$ gem install tl1
|
32
|
+
|
33
|
+
## Usage
|
34
|
+
|
35
|
+
### Connecting
|
36
|
+
|
37
|
+
The first step is to instantiate some sort of I/O object to handle the
|
38
|
+
lower-level communications with the target device. The TL1 protocol rides on top
|
39
|
+
of other networking protocols such as SSH or telnet, but `TL1` doesn't have any
|
40
|
+
methods to open a socket. Instead, you will probably want to use
|
41
|
+
`Net::SSH::Telnet` (or perhaps `Net::Telnet`) to establish an interactive
|
42
|
+
sesssion, and pass that object in to `TL1::Session.new`:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
require 'net/ssh/telnet'
|
46
|
+
require 'tl1'
|
47
|
+
ssh = Net::SSH::Telnet.new(
|
48
|
+
'Host' => hostname,
|
49
|
+
'Port' => port,
|
50
|
+
'Username' => username,
|
51
|
+
'Password' => password
|
52
|
+
)
|
53
|
+
tl1 = TL1::Session.new(ssh)
|
54
|
+
```
|
55
|
+
|
56
|
+
If you are (carefully!) using Telnet instead of SSH, you can substitute
|
57
|
+
`Net::Telnet` for `Net::SSH::Telnet`. If neither of those works for you, you can
|
58
|
+
try using any `IO` subclass (for example a pipe) in conjunction with `expect`
|
59
|
+
from the standard library, which adds an `#expect` method to `IO`. The only
|
60
|
+
methods needed on the object passed to `TL1::Session.new` are `#write` and
|
61
|
+
`#expect`.
|
62
|
+
|
63
|
+
If you need to log in, change modes, or take any other action to prime the
|
64
|
+
target device to speak TL1, outside of the TL1 protocol, do that before calling
|
65
|
+
`TL1::Session.new`. `ACT-USER`, `CANC-USER`, and other session management
|
66
|
+
actions that occur within the TL1 protocol should be done via the
|
67
|
+
`TL1::Session`.
|
68
|
+
|
69
|
+
### Commands
|
70
|
+
|
71
|
+
In `tl1`, commands are objects that define a format for constructing an input
|
72
|
+
message (the "command") and a format for reading an output message (the
|
73
|
+
"response"). The syntax of the format string is intended to be close to the
|
74
|
+
documentation supplied by the vendor.
|
75
|
+
|
76
|
+
A handful of sample commands for the BTI 7200 platform are defined in
|
77
|
+
`tl1/platforms/bti.rb`.
|
78
|
+
|
79
|
+
A new command can be defined using `TL1::Command.new`:
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
RTRV_EQPT = TL1::Command.new(
|
83
|
+
'RTRV-EQPT',
|
84
|
+
'<aid>:<type>:ID=<id>,C1=<custom1>,C2=<custom2>,C3=<custom3>:<pst>,<sst>'
|
85
|
+
)
|
86
|
+
tl1.cmd(RTRV_EQPT)
|
87
|
+
```
|
88
|
+
|
89
|
+
The first argument to `TL1::Command.new` is an input message format: in its
|
90
|
+
simplest form, a command string to be sent to the target device.
|
91
|
+
|
92
|
+
The second argument to `TL1::Command.new` is an output message format. Records
|
93
|
+
in the output message are extracted and converted into hashes according to the
|
94
|
+
format string. Records are divided into fields, which are separated by colons,
|
95
|
+
may be further separated by commas, and if separated by commas, may be labeled
|
96
|
+
with a keyword. If the second argument is omitted (or `nil`), the raw output for
|
97
|
+
that command will be returned as a string, with no parsing.
|
98
|
+
|
99
|
+
Examples of each type of field are given above. `aid` and `type` are simple
|
100
|
+
strings. `id`, `custom1`, `custom2`, and `custom3` are keyword-labeled strings
|
101
|
+
sharing a single field, and separated by commas within that field. `pst` and
|
102
|
+
`sst` are strings sharing a single field and separated by commas within that
|
103
|
+
field.
|
104
|
+
|
105
|
+
When `TL1::Session#cmd` is called with a `TL1::Command` argument, the command's
|
106
|
+
defined input message is sent to the session's underlying IO, and the received
|
107
|
+
output message is processed according to the output format string.
|
108
|
+
|
109
|
+
An output message for the `RTRV_EQPT` command might look like this:
|
110
|
+
|
111
|
+
```
|
112
|
+
bti7200hoge 17-08-31 16:29:53
|
113
|
+
M 100 COMPLD
|
114
|
+
"MS-1:BT7A51AR::IS-NR,"
|
115
|
+
"D40MD-0-2:BT7A37AA::,"
|
116
|
+
;
|
117
|
+
BTI7000>
|
118
|
+
```
|
119
|
+
|
120
|
+
And then the returned array of records for that output message will look like
|
121
|
+
this:
|
122
|
+
|
123
|
+
```ruby
|
124
|
+
[
|
125
|
+
{ aid: 'MS-1', type: 'BT7A51AR', pst: 'IS-NR', sst: '' },
|
126
|
+
{ aid: 'D40MD-0-2', type: 'BT7A37AA', pst: '', sst: '' }
|
127
|
+
]
|
128
|
+
```
|
129
|
+
|
130
|
+
Note that the keyword-labeled fields are missing (because their keywords are
|
131
|
+
missing), while other fields that are empty are present and represented as empty
|
132
|
+
strings.
|
133
|
+
|
134
|
+
Some commands will accept arguments. For example, a more complete implementation
|
135
|
+
of `RTRV_EQPT` will accept an `aid` to narrow the query:
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
RTRV_EQPT = TL1::Command.new(
|
139
|
+
'RTRV-EQPT:<tid>:<aid>:<ctag>',
|
140
|
+
'<aid>:<type>:ID=<id>,C1=<custom1>,C2=<custom2>,C3=<custom3>:<pst>,<sst>'
|
141
|
+
)
|
142
|
+
tl1.cmd(RTRV_EQPT, aid: 'MS-1')
|
143
|
+
# => [{ aid: 'MS-1', type: 'BT7A51AR', pst: 'IS-NR', sst: '' }]
|
144
|
+
```
|
145
|
+
|
146
|
+
The input and output format strings have the same syntax. Items in angle
|
147
|
+
brackets are optional variables, unbracketed text is literal. It's common for
|
148
|
+
vendors to specify a field as "mandatory" or "optional", but we don't make any
|
149
|
+
attempt to distinguish between them here; in effect, all fields are treated as
|
150
|
+
optional.
|
151
|
+
|
152
|
+
Most fields will be variables, but occasionally there will be a literal field.
|
153
|
+
In the following command definition, the first field of the output format is a
|
154
|
+
literal representing the empty string (`""`), and the last field is a literal
|
155
|
+
representing the string `"asdf"`:
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
RTRV_ROUTE_CONN = Command.new(
|
159
|
+
'RTRV-ROUTE-CONN',
|
160
|
+
':<ipaddr>,<mask>,<nexthop>:COST=<cost>,ADMINDIST=<admindist>:asdf'
|
161
|
+
)
|
162
|
+
```
|
163
|
+
|
164
|
+
Literals don't affect the output of parsing, but they will raise an exception if
|
165
|
+
the output message has text in that field that does not exactly match the
|
166
|
+
literal. In the above command definition, all output records are required to
|
167
|
+
start with `":"` and end with `":asdf"`.
|
168
|
+
|
169
|
+
## Development
|
170
|
+
|
171
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
172
|
+
|
173
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
174
|
+
|
175
|
+
## Contributing
|
176
|
+
|
177
|
+
Bug reports and pull requests are welcome on GitHub at
|
178
|
+
https://github.com/bjmllr/tl1 . This project is intended to be a safe, welcoming
|
179
|
+
space for collaboration, and contributors are expected to adhere to the
|
180
|
+
[Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
181
|
+
|
182
|
+
Please include tests with any pull request. If you need help with testing,
|
183
|
+
please open an issue.
|
184
|
+
|
185
|
+
## License
|
186
|
+
|
187
|
+
The gem is available as open source under the terms of the [GNU General Public
|
188
|
+
License version 3](http://opensource.org/licenses/GPL-3.0).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'tl1'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require 'irb'
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/lib/tl1.rb
ADDED
data/lib/tl1/ast.rb
ADDED
@@ -0,0 +1,268 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'strscan'
|
3
|
+
|
4
|
+
module TL1
|
5
|
+
# Namespace for AST Nodes in input and output formats
|
6
|
+
module AST
|
7
|
+
module_function def parse_message_format(source)
|
8
|
+
ColonSeparatedVariables.parse(source)
|
9
|
+
end
|
10
|
+
|
11
|
+
module_function def colon_separated_element(source)
|
12
|
+
return from_json(source) if source.is_a?(Hash)
|
13
|
+
raise "Unparseable element #{source}" unless source.is_a?(String)
|
14
|
+
|
15
|
+
if source.include?('=')
|
16
|
+
CommaSeparatedKeywordVariables.parse(source)
|
17
|
+
elsif source.include?(',')
|
18
|
+
CommaSeparatedVariables.parse(source)
|
19
|
+
elsif source.start_with?('<') && source.end_with?('>')
|
20
|
+
Variable.parse(source)
|
21
|
+
else
|
22
|
+
Literal.parse(source)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module_function def from_json(source)
|
27
|
+
node = NODES_BY_NAME.fetch(source['node']) do
|
28
|
+
raise "Unknown node type #{source['node']}"
|
29
|
+
end
|
30
|
+
|
31
|
+
node.parse(source['fields'])
|
32
|
+
end
|
33
|
+
|
34
|
+
module_function def split(string, delimiter)
|
35
|
+
scanner = StringScanner.new(string)
|
36
|
+
array = [String.new]
|
37
|
+
|
38
|
+
loop do
|
39
|
+
return array if scanner.eos?
|
40
|
+
char = scanner.getch
|
41
|
+
case char
|
42
|
+
when delimiter
|
43
|
+
array << String.new
|
44
|
+
when '"'
|
45
|
+
array.last << split_quoted(scanner)
|
46
|
+
else
|
47
|
+
array.last << char
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
module_function def split_quoted(scanner)
|
53
|
+
string = String.new('"')
|
54
|
+
|
55
|
+
loop do
|
56
|
+
raise 'Unexpected end of quoted string' if scanner.eos?
|
57
|
+
char = scanner.getch
|
58
|
+
case char
|
59
|
+
when '"'
|
60
|
+
string << '"'
|
61
|
+
return string
|
62
|
+
else
|
63
|
+
string << char
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
module_function def remove_quotes(string)
|
69
|
+
if string.start_with?('"') && string.end_with?('"')
|
70
|
+
string[1..-2]
|
71
|
+
else
|
72
|
+
string
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# The base class for all AST nodes
|
77
|
+
class Node
|
78
|
+
include Comparable
|
79
|
+
attr_reader :fields
|
80
|
+
|
81
|
+
def <=>(other)
|
82
|
+
return unless other.is_a?(Node)
|
83
|
+
fields <=> other.fields
|
84
|
+
end
|
85
|
+
|
86
|
+
def as_json(fields = nil)
|
87
|
+
fields ||= @fields
|
88
|
+
{ node: NODES_BY_CLASS[self.class], fields: fields }
|
89
|
+
end
|
90
|
+
|
91
|
+
def to_json
|
92
|
+
as_json.to_json
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# A sequence of fields or groups of fields separated by colons. This is the
|
97
|
+
# "root" AST node for every input message and every record in an output
|
98
|
+
# message.
|
99
|
+
class ColonSeparatedVariables < Node
|
100
|
+
def self.parse(source)
|
101
|
+
elements =
|
102
|
+
if source.is_a?(String)
|
103
|
+
AST.split(source, ':')
|
104
|
+
else
|
105
|
+
source.fetch('fields')
|
106
|
+
end
|
107
|
+
|
108
|
+
new(*elements.map { |e| AST.colon_separated_element(e) })
|
109
|
+
end
|
110
|
+
|
111
|
+
def initialize(*fields)
|
112
|
+
@fields = fields
|
113
|
+
end
|
114
|
+
|
115
|
+
def as_json
|
116
|
+
super(@fields.map(&:as_json))
|
117
|
+
end
|
118
|
+
|
119
|
+
def format(**kwargs)
|
120
|
+
fields.map { |f| f.format(**kwargs) }.join(':')
|
121
|
+
end
|
122
|
+
|
123
|
+
def parse(record_source, record: {})
|
124
|
+
pairs = AST.split(record_source, ':').zip(@fields)
|
125
|
+
|
126
|
+
pairs.each do |fragment, node|
|
127
|
+
node.parse(fragment, record: record)
|
128
|
+
end
|
129
|
+
|
130
|
+
record
|
131
|
+
end
|
132
|
+
end # class ColonSeparatedVariables
|
133
|
+
|
134
|
+
# A group of fields separated by commas with a fixed order.
|
135
|
+
class CommaSeparatedVariables < Node
|
136
|
+
def self.parse(source)
|
137
|
+
elements =
|
138
|
+
if source.is_a?(String)
|
139
|
+
AST.split(source, ',').map { |e| Variable.parse(e) }
|
140
|
+
else
|
141
|
+
source.map { |e| Variable.parse(e['fields']) }
|
142
|
+
end
|
143
|
+
|
144
|
+
new(*elements)
|
145
|
+
end
|
146
|
+
|
147
|
+
def initialize(*fields)
|
148
|
+
@fields = fields
|
149
|
+
end
|
150
|
+
|
151
|
+
def as_json
|
152
|
+
super(fields.map(&:as_json))
|
153
|
+
end
|
154
|
+
|
155
|
+
def format(**kwargs)
|
156
|
+
fields.map { |f| f.format(**kwargs) }.join(',')
|
157
|
+
end
|
158
|
+
|
159
|
+
def parse(source, record:)
|
160
|
+
AST.split(source, ',').zip(fields).each do |value, field|
|
161
|
+
field.parse(value, record: record)
|
162
|
+
end
|
163
|
+
|
164
|
+
record
|
165
|
+
end
|
166
|
+
end # class CommaSeparatedVariables
|
167
|
+
|
168
|
+
# A group of fields separated by commas and identified by keywords. Fields
|
169
|
+
# may appear in any order.
|
170
|
+
class CommaSeparatedKeywordVariables < Node
|
171
|
+
def self.parse(source)
|
172
|
+
elements =
|
173
|
+
if source.is_a?(String)
|
174
|
+
AST.split(source, ',').map { |pair|
|
175
|
+
key, value = pair.split('=', 2)
|
176
|
+
[key.to_s, Variable.parse(value)]
|
177
|
+
}.to_h
|
178
|
+
else
|
179
|
+
source.map { |k, v| [k.to_s, Variable.parse(v['fields'])] }.to_h
|
180
|
+
end
|
181
|
+
|
182
|
+
new(elements)
|
183
|
+
end
|
184
|
+
|
185
|
+
def as_json
|
186
|
+
super(fields.keys.zip(fields.values.map(&:as_json)).to_h)
|
187
|
+
end
|
188
|
+
|
189
|
+
def initialize(fields)
|
190
|
+
@fields = fields
|
191
|
+
end
|
192
|
+
|
193
|
+
def format(**kwargs)
|
194
|
+
fields.each_pair.flat_map { |keyword, variable|
|
195
|
+
if kwargs.key?(variable.fields)
|
196
|
+
["#{keyword}=#{kwargs[variable.fields]}"]
|
197
|
+
else
|
198
|
+
[]
|
199
|
+
end
|
200
|
+
}.join(',')
|
201
|
+
end
|
202
|
+
|
203
|
+
def parse(fragment, record:)
|
204
|
+
AST.split(fragment, ',').each do |pair|
|
205
|
+
next if pair.empty?
|
206
|
+
key, value = pair.split('=', 2)
|
207
|
+
field_name = @fields.fetch(key)
|
208
|
+
record[field_name.fields] = AST.remove_quotes(value)
|
209
|
+
end
|
210
|
+
|
211
|
+
record
|
212
|
+
end
|
213
|
+
end # class CommaSeparatedKeywordVariables
|
214
|
+
|
215
|
+
# A literal string. Not included in parsing output, but must match.
|
216
|
+
class Literal < Node
|
217
|
+
def self.parse(source)
|
218
|
+
new(source)
|
219
|
+
end
|
220
|
+
|
221
|
+
def initialize(fields)
|
222
|
+
@fields = fields.to_str
|
223
|
+
end
|
224
|
+
|
225
|
+
def format(*)
|
226
|
+
fields
|
227
|
+
end
|
228
|
+
|
229
|
+
def parse(source, **)
|
230
|
+
return if source == format
|
231
|
+
raise "Message literal does not match format literal #{format.inspect}"
|
232
|
+
end
|
233
|
+
end # class Literal
|
234
|
+
|
235
|
+
# A variable string. Included in parsing output.
|
236
|
+
class Variable < Node
|
237
|
+
def self.parse(source)
|
238
|
+
new(optional_variable(source))
|
239
|
+
end
|
240
|
+
|
241
|
+
def self.optional_variable(token)
|
242
|
+
token.match(/\A<(.*)>\z/) { |m| m[1].to_sym } || token.to_sym
|
243
|
+
end
|
244
|
+
|
245
|
+
def initialize(fields)
|
246
|
+
@fields = fields
|
247
|
+
end
|
248
|
+
|
249
|
+
def format(**kwargs)
|
250
|
+
kwargs[@fields]
|
251
|
+
end
|
252
|
+
|
253
|
+
def parse(fragment, record:)
|
254
|
+
record[fields] = AST.remove_quotes(fragment)
|
255
|
+
end
|
256
|
+
end # class Variable
|
257
|
+
|
258
|
+
NODES_BY_NAME = [
|
259
|
+
ColonSeparatedVariables,
|
260
|
+
CommaSeparatedKeywordVariables,
|
261
|
+
CommaSeparatedVariables,
|
262
|
+
Literal,
|
263
|
+
Variable
|
264
|
+
].map { |n| [n.to_s.split('::').last, n] }.to_h.freeze
|
265
|
+
|
266
|
+
NODES_BY_CLASS = NODES_BY_NAME.invert.freeze
|
267
|
+
end # class AST
|
268
|
+
end # module TL1
|