tl1 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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).
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ require 'bundler/gem_tasks'
3
+ require 'rspec/core/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
@@ -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__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ require 'tl1/ast'
3
+ require 'tl1/command'
4
+ require 'tl1/input_format'
5
+ require 'tl1/output_format'
6
+ require 'tl1/session'
7
+ require 'tl1/version'
8
+
9
+ module TL1
10
+ COMPLD = /COMPLD[\n\r]{1,2}.*;/m
11
+ end # module TL1
@@ -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