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.
@@ -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