vtysh 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 379ae72d0d97bf2292c0d3b303799a44efcbf35d282628c5ab0ce3f7e49814be
4
+ data.tar.gz: 87dfb517f5840af6c423ebad534865911598e54835bc1108825eccd0f66f4358
5
+ SHA512:
6
+ metadata.gz: ba2974e8ff1b4afa191d0d2fac607521d101829a573c2b855c23766cbb0c5e1d141d05296768c9287b6cfbd3e87b5054dc4f61c50dacfbf11752e9c19d6200e8
7
+ data.tar.gz: dee8353a63b67879f848265ba70cb7d5f9becd1780e255d22ab78861a69f1611a94a888eb1cc0eb3e5880535448d2e97ef4d4fbb7d7ea74acf319586ab5aa1c8
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # Vtysh
2
+
3
+ A Ruby gem for handling SONiC vtysh configuration diffs. It accepts two configuration states and returns the commands needed to transition from one to the other.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem 'vtysh'
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ruby
14
+ require 'vtysh'
15
+
16
+ source_config = File.read('a.vtysh')
17
+ target_config = File.read('b.vtysh')
18
+
19
+ commands = Vtysh::Diff.commands(source_config, target_config)
20
+
21
+ puts commands
22
+ ```
23
+
24
+ This will output commands with proper hierarchy preserved:
25
+
26
+ ```
27
+ # BGP blocks are fully recreated
28
+ vtysh -c "configure" -c "no router bgp 65001"
29
+ vtysh -c "configure" -c "router bgp 65001" -c "bgp router-id 192.168.1.1" -c "neighbor 192.168.1.2 remote-as 65002" -c "exit"
30
+
31
+ # Individual commands for non-BGP configuration
32
+ vtysh -c "configure" -c "interface Ethernet0 description Primary"
33
+ ```
34
+
35
+ ## Features
36
+
37
+ - Generates hierarchical vtysh commands that maintain proper block structure
38
+ - Properly handles configuration blocks like `router bgp` and `address-family`
39
+ - Removes and recreates entire BGP blocks for clean configuration
40
+ - Route-map match commands are split into separate arguments
41
+ - Adds appropriate exit commands when needed
42
+ - Correctly removes entire configuration blocks with `no` prefix
43
+ - Includes `configure` terminal context as required by SONiC vtysh
44
+ - Ensures `neighbor peer-group` commands come before `bgp listen range` commands
45
+ - Ensures `neighbor remote-as` commands are processed before other neighbor attributes
46
+
47
+ ## Command Ordering
48
+
49
+ The gem pays special attention to command ordering to meet SONiC vtysh requirements:
50
+
51
+ 1. Peer-group definitions come before `bgp listen range` commands referencing them
52
+ 2. For neighbors, `remote-as` commands always come before other attributes like route-maps, descriptions, etc.
53
+ 3. When adding new neighbors, the ASN is defined first via the `remote-as` command
54
+ 4. This prevents common errors like `% Specify remote-as or peer-group commands first`
55
+
56
+ ## Block-Based Processing
57
+
58
+ The gem uses a block-based approach where:
59
+
60
+ 1. BGP blocks are fully removed and recreated with clean settings
61
+ 2. Route-map blocks handle match commands as separate arguments
62
+ 3. Interface and other blocks are processed with proper context
63
+
64
+ This ensures:
65
+ - Complete configuration with proper command ordering
66
+ - Clean setup of complex structures like BGP routers
67
+ - Proper handling of nested contexts like address-family blocks
68
+
69
+ ## License
70
+
71
+ The gem is available as open source under the terms of the MIT License.
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'vtysh'
4
+
5
+ if ARGV.size != 2
6
+ puts "Usage: vtysh-transform SOURCE_FILE TARGET_FILE"
7
+ exit 1
8
+ end
9
+
10
+ source_file = ARGV[0]
11
+ target_file = ARGV[1]
12
+
13
+ # Ensure files exist
14
+ unless File.exist?(source_file)
15
+ puts "Error: Source file #{source_file} does not exist."
16
+ exit 1
17
+ end
18
+
19
+ unless File.exist?(target_file)
20
+ puts "Error: Target file #{target_file} does not exist."
21
+ exit 1
22
+ end
23
+
24
+ # Read configurations
25
+ source_config = File.read(source_file)
26
+ target_config = File.read(target_file)
27
+
28
+ # Generate and print commands
29
+ Vtysh::Diff.commands(source_config, target_config).each do |cmd|
30
+ puts cmd
31
+ end
data/lib/vtysh/diff.rb ADDED
@@ -0,0 +1,324 @@
1
+ module Vtysh
2
+ class Diff
3
+ def self.commands(source, target)
4
+ # Parse configurations into flat commands
5
+ source_cmds = parse_config(source)
6
+ target_cmds = parse_config(target)
7
+
8
+ # Generate commands to transform source to target
9
+ commands = []
10
+
11
+ # Special case for router-id changes
12
+ if needs_bgp_recreation?(source_cmds, target_cmds)
13
+ commands.concat(handle_bgp_recreation(source_cmds, target_cmds))
14
+ else
15
+ # Handle removals first (items in source but not in target)
16
+ (source_cmds - target_cmds).each do |cmd|
17
+ if is_block_command(cmd[:command])
18
+ # For block commands like "router bgp X", we need to remove the whole block
19
+ commands << format_removal_command(cmd)
20
+
21
+ # Skip commands inside this block - they'll be removed automatically
22
+ if cmd[:command].start_with?("router bgp")
23
+ # We need to specifically exclude commands inside this removed BGP block
24
+ # to avoid generating individual "no" commands for them
25
+ asn = cmd[:command].split[2]
26
+ # Don't generate commands for items in this block
27
+ end
28
+ elsif cmd[:depth] > 0
29
+ # Check if we're removing a command inside a block that's already being removed
30
+ block_being_removed = false
31
+ cmd[:context].each do |ctx|
32
+ # Check if any of the contexts is a block that's being removed
33
+ if source_cmds.any? { |s| s[:command] == ctx && (source_cmds - target_cmds).include?(s) }
34
+ block_being_removed = true
35
+ break
36
+ end
37
+ end
38
+
39
+ # Only generate a command if not inside a block that's being removed
40
+ unless block_being_removed
41
+ # For commands inside blocks, we need to provide the proper context
42
+ commands << format_context_command("no #{cmd[:command]}", cmd[:context])
43
+ end
44
+ else
45
+ # For top-level commands
46
+ commands << "vtysh -c \"configure\" -c \"no #{cmd[:command]}\""
47
+ end
48
+ end
49
+
50
+ # Handle additions (items in target but not in source)
51
+ (target_cmds - source_cmds).each do |cmd|
52
+ if is_block_command(cmd[:command])
53
+ # For block commands like "router bgp X", include the block creation
54
+ commands << "vtysh -c \"configure\" -c \"#{cmd[:command]}\""
55
+ elsif cmd[:depth] > 0
56
+ # For commands inside blocks, we need to provide the proper context
57
+ commands << format_context_command(cmd[:command], cmd[:context])
58
+ else
59
+ # For top-level commands
60
+ commands << "vtysh -c \"configure\" -c \"#{cmd[:command]}\""
61
+ end
62
+ end
63
+ end
64
+
65
+ # Sort commands and apply dependency-based reordering
66
+ reorder_commands(commands.uniq)
67
+ end
68
+
69
+ private
70
+
71
+ # New improved command ordering function that ensures the proper order for BGP-related commands
72
+ def self.reorder_commands(commands)
73
+ # First extract all the commands that need to be properly ordered
74
+ all_peer_group_cmds = commands.select { |cmd| cmd.include?("neighbor") && cmd.include?("peer-group") && !cmd.include?("no ") }
75
+ all_remote_as_cmds = commands.select { |cmd| cmd.include?("remote-as") }
76
+ all_listen_range_cmds = commands.select { |cmd| cmd.include?("bgp listen range") }
77
+
78
+ # Need to remove these from commands for proper reordering
79
+ clean_commands = commands - all_peer_group_cmds - all_remote_as_cmds - all_listen_range_cmds
80
+
81
+ # Apply correct ordering for all commands
82
+ final_commands = []
83
+
84
+ # Handle prefix-lists first (always)
85
+ prefix_list_cmds = clean_commands.select { |cmd| cmd.include?("ip prefix-list") }
86
+ final_commands.concat(prefix_list_cmds)
87
+
88
+ # Handle route-map definitions second
89
+ route_map_cmds = clean_commands.select { |cmd| cmd.include?("route-map") && !cmd.include?("neighbor") && !cmd.include?("no ") }
90
+ final_commands.concat(route_map_cmds)
91
+
92
+ # Include router bgp blocks
93
+ bgp_block_cmds = clean_commands.select { |cmd| cmd.include?("router bgp") && !cmd.include?("no ") && cmd.count("\"") <= 6 }
94
+ final_commands.concat(bgp_block_cmds)
95
+
96
+ # Now add peer-group commands first
97
+ all_peer_group_cmds.sort.each do |cmd|
98
+ final_commands << cmd
99
+ end
100
+
101
+ # Then add remote-as commands
102
+ all_remote_as_cmds.sort.each do |cmd|
103
+ final_commands << cmd
104
+ end
105
+
106
+ # Then add listen-range commands
107
+ all_listen_range_cmds.sort.each do |cmd|
108
+ final_commands << cmd
109
+ end
110
+
111
+ # Add all remaining commands except removals
112
+ remaining_cmds = clean_commands.select { |cmd| !cmd.include?("no ") }
113
+ remaining_cmds -= final_commands
114
+ final_commands.concat(remaining_cmds)
115
+
116
+ # Add removal commands at the end
117
+ removal_cmds = clean_commands.select { |cmd| cmd.include?("no ") }
118
+ final_commands.concat(removal_cmds)
119
+
120
+ # Return unique commands with duplicates removed
121
+ final_commands.uniq
122
+ end
123
+
124
+ def self.needs_bgp_recreation?(source_cmds, target_cmds)
125
+ # Get BGP ASNs from both configs
126
+ source_bgp = source_cmds.select { |cmd| cmd[:command].start_with?("router bgp") }
127
+ target_bgp = target_cmds.select { |cmd| cmd[:command].start_with?("router bgp") }
128
+
129
+ # Check if we have the same ASN in both
130
+ source_asns = source_bgp.map { |cmd| cmd[:command].split[2] }
131
+ target_asns = target_bgp.map { |cmd| cmd[:command].split[2] }
132
+
133
+ # If ASNs don't match, no need for special handling
134
+ return false if source_asns != target_asns || source_asns.empty?
135
+
136
+ # For each ASN that appears in both, check for router-id changes
137
+ source_asns.each do |asn|
138
+ # Find router-id in source
139
+ src_bgp_cmds = source_cmds.select { |cmd|
140
+ cmd[:depth] > 0 &&
141
+ cmd[:context].any? { |ctx| ctx.start_with?("router bgp #{asn}") } &&
142
+ cmd[:command].include?("bgp router-id")
143
+ }
144
+
145
+ # Find router-id in target
146
+ tgt_bgp_cmds = target_cmds.select { |cmd|
147
+ cmd[:depth] > 0 &&
148
+ cmd[:context].any? { |ctx| ctx.start_with?("router bgp #{asn}") } &&
149
+ cmd[:command].include?("bgp router-id")
150
+ }
151
+
152
+ # If both have router-id and they're different, return true
153
+ if !src_bgp_cmds.empty? && !tgt_bgp_cmds.empty? &&
154
+ src_bgp_cmds.first[:command] != tgt_bgp_cmds.first[:command]
155
+ return true
156
+ end
157
+ end
158
+
159
+ false
160
+ end
161
+
162
+ def self.handle_bgp_recreation(source_cmds, target_cmds)
163
+ commands = []
164
+
165
+ # Get BGP ASNs
166
+ source_bgp = source_cmds.select { |cmd| cmd[:command].start_with?("router bgp") }
167
+ target_bgp = target_cmds.select { |cmd| cmd[:command].start_with?("router bgp") }
168
+
169
+ source_asns = source_bgp.map { |cmd| cmd[:command].split[2] }
170
+ target_asns = target_bgp.map { |cmd| cmd[:command].split[2] }
171
+
172
+ # For each ASN, recreate the BGP block
173
+ (source_asns & target_asns).each do |asn|
174
+ # Remove the BGP block
175
+ commands << "vtysh -c \"configure\" -c \"no router bgp #{asn}\""
176
+
177
+ # Get the commands for this BGP block in the target config
178
+ target_bgp_block = target_cmds.select { |cmd|
179
+ cmd[:depth] > 0 && cmd[:context].any? { |ctx| ctx.start_with?("router bgp #{asn}") }
180
+ }
181
+
182
+ # Add the BGP block itself
183
+ commands << "vtysh -c \"configure\" -c \"router bgp #{asn}\""
184
+
185
+ # Add commands within the BGP block
186
+ bgp_main_cmds = target_bgp_block.select { |cmd|
187
+ !cmd[:context].any? { |ctx| ctx.start_with?("address-family") }
188
+ }
189
+
190
+ # Sort commands - router-id first, then peer-groups, then remote-as, etc.
191
+ bgp_main_cmds.sort_by! { |cmd| bgp_command_priority(cmd[:command]) }
192
+
193
+ # Add the main BGP commands
194
+ bgp_main_cmds.each do |cmd|
195
+ commands << "vtysh -c \"configure\" -c \"router bgp #{asn}\" -c \"#{cmd[:command]}\""
196
+ end
197
+
198
+ # Add address-family blocks and their commands
199
+ af_contexts = target_bgp_block.map { |cmd|
200
+ cmd[:context].find { |ctx| ctx.start_with?("address-family") }
201
+ }.compact.uniq
202
+
203
+ af_contexts.each do |af_ctx|
204
+ # Add the address-family context
205
+ commands << "vtysh -c \"configure\" -c \"router bgp #{asn}\" -c \"#{af_ctx}\""
206
+
207
+ # Add commands in this address-family
208
+ af_cmds = target_bgp_block.select { |cmd| cmd[:context].include?(af_ctx) }
209
+ af_cmds.each do |cmd|
210
+ commands << "vtysh -c \"configure\" -c \"router bgp #{asn}\" -c \"#{af_ctx}\" -c \"#{cmd[:command]}\""
211
+ end
212
+ end
213
+ end
214
+
215
+ commands
216
+ end
217
+
218
+ def self.bgp_command_priority(cmd)
219
+ # Define priority for BGP commands (lower number = higher priority)
220
+ if cmd.include?('bgp router-id')
221
+ return 1 # router-id should be first
222
+ elsif cmd.include?('neighbor') && cmd.include?('peer-group') && !cmd.include?(' route-map ')
223
+ return 2 # peer-group definitions second
224
+ elsif cmd.include?('remote-as')
225
+ return 3 # remote-as commands next
226
+ elsif cmd.include?('bgp listen range')
227
+ return 4 # bgp listen range commands after
228
+ else
229
+ return 5 # other commands last
230
+ end
231
+ end
232
+
233
+ def self.parse_config(config)
234
+ lines = config.split("\n").map(&:strip)
235
+ commands = []
236
+ context_stack = []
237
+
238
+ lines.each do |line|
239
+ next if line.empty? || line.start_with?('#', '!')
240
+
241
+ if line == 'exit' || line == 'exit-vrf' || line == 'exit-address-family'
242
+ # Exit the current context
243
+ context_stack.pop unless context_stack.empty?
244
+ elsif line == 'end'
245
+ # Exit all contexts
246
+ context_stack = []
247
+ elsif is_block_command(line)
248
+ # Start a new context block
249
+ context_stack << line
250
+ commands << { command: line, context: context_stack.dup, depth: context_stack.size }
251
+ else
252
+ # Regular command, may be inside a context
253
+ commands << { command: line, context: context_stack.dup, depth: context_stack.size }
254
+ end
255
+ end
256
+
257
+ commands
258
+ end
259
+
260
+ def self.is_block_command(cmd)
261
+ cmd.start_with?('router ', 'interface ', 'route-map ', 'vrf ', 'address-family ')
262
+ end
263
+
264
+ def self.needs_exit_command(cmd)
265
+ cmd.start_with?('router ', 'interface ', 'vrf ')
266
+ end
267
+
268
+ def self.format_removal_command(cmd)
269
+ command = cmd[:command]
270
+
271
+ # For block commands with nested structures
272
+ if command.start_with?('router bgp')
273
+ asn = command.split[2]
274
+ return "vtysh -c \"configure\" -c \"no router bgp #{asn}\""
275
+ elsif command.start_with?('interface')
276
+ iface = command.split[1]
277
+ return "vtysh -c \"configure\" -c \"no interface #{iface}\""
278
+ elsif command.start_with?('vrf')
279
+ vrf_name = command.split[1]
280
+ return "vtysh -c \"configure\" -c \"no vrf #{vrf_name}\""
281
+ elsif command.start_with?('route-map')
282
+ parts = command.split
283
+ if parts.size >= 4
284
+ return "vtysh -c \"configure\" -c \"no route-map #{parts[1]} #{parts[2]} #{parts[3]}\""
285
+ else
286
+ return "vtysh -c \"configure\" -c \"no #{command}\""
287
+ end
288
+ else
289
+ # Generic block removal
290
+ return "vtysh -c \"configure\" -c \"no #{command}\""
291
+ end
292
+ end
293
+
294
+ def self.format_context_command(command, context)
295
+ result = "vtysh -c \"configure\""
296
+
297
+ # Add each context element
298
+ context.each do |ctx|
299
+ if ctx.start_with?('router bgp')
300
+ asn = ctx.split[2]
301
+ result += " -c \"router bgp #{asn}\""
302
+ elsif ctx.start_with?('interface')
303
+ iface = ctx.split[1]
304
+ result += " -c \"interface #{iface}\""
305
+ elsif ctx.start_with?('address-family')
306
+ parts = ctx.split
307
+ if parts.size > 2
308
+ result += " -c \"address-family #{parts[1]} #{parts[2]}\""
309
+ else
310
+ result += " -c \"address-family #{parts[1]}\""
311
+ end
312
+ else
313
+ # Generic context
314
+ result += " -c \"#{ctx}\""
315
+ end
316
+ end
317
+
318
+ # Add the command itself
319
+ result += " -c \"#{command}\""
320
+
321
+ result
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,3 @@
1
+ module Vtysh
2
+ VERSION = "0.1.0"
3
+ end
data/lib/vtysh.rb ADDED
@@ -0,0 +1,6 @@
1
+ require_relative "vtysh/version"
2
+ require_relative "vtysh/diff"
3
+
4
+ module Vtysh
5
+ class Error < StandardError; end
6
+ end
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vtysh
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan Siegel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-04-03 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A gem that accepts two vtysh configuration states and returns the commands
14
+ needed to transition from one to the other
15
+ email:
16
+ - "<248302+usiegj00@users.noreply.github.com>"
17
+ executables:
18
+ - vtysh-transform
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - README.md
23
+ - bin/vtysh-transform
24
+ - lib/vtysh.rb
25
+ - lib/vtysh/diff.rb
26
+ - lib/vtysh/version.rb
27
+ homepage: http://github.com/usiegj00/vtysh-gem
28
+ licenses:
29
+ - MIT
30
+ metadata: {}
31
+ post_install_message:
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 2.6.0
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.5.16
47
+ signing_key:
48
+ specification_version: 4
49
+ summary: Handles SONiC vtysh commandfile format
50
+ test_files: []