Class: Vtysh::Diff

Inherits:
Object
  • Object
show all
Defined in:
lib/vtysh/diff.rb

Class Method Summary collapse

Class Method Details

.bgp_command_priority(cmd) ⇒ Object



150
151
152
153
154
155
156
157
158
159
# File 'lib/vtysh/diff.rb', line 150

def self.bgp_command_priority(cmd)
  case
  when cmd.include?('bgp router-id') then 1
  when cmd =~ /neighbor \S+ peer-group$/ then 2
  when cmd.include?('remote-as') then 3
  when cmd =~ /neighbor \S+ local-as/ then 4
  when cmd.include?('bgp listen range') then 5
  else 6
  end
end

.block_being_removed?(cmd, source_cmds, target_cmds) ⇒ Boolean

Returns:

  • (Boolean)


130
131
132
133
134
135
# File 'lib/vtysh/diff.rb', line 130

def self.block_being_removed?(cmd, source_cmds, target_cmds)
  removals = source_cmds - target_cmds
  cmd[:context].any? { |ctx|
    is_block_command(ctx) && removals.any? { |r| r[:command] == ctx }
  }
end

.commands(source, target) ⇒ Object



3
4
5
6
7
8
9
10
11
12
13
14
15
# File 'lib/vtysh/diff.rb', line 3

def self.commands(source, target)
  source_cmds = parse_config(source)
  target_cmds = parse_config(target)

  if needs_bgp_recreation?(source_cmds, target_cmds)
    bgp_commands = handle_bgp_recreation(source_cmds, target_cmds)
    non_bgp_commands = handle_non_bgp_changes(source_cmds, target_cmds, skip_bgp_blocks: true)
    bgp_commands + non_bgp_commands
  else
    handle_non_bgp_changes(source_cmds, target_cmds) +
      handle_incremental_bgp_changes(source_cmds, target_cmds)
  end
end

.format_context_command(command, context) ⇒ Object



255
256
257
258
# File 'lib/vtysh/diff.rb', line 255

def self.format_context_command(command, context)
  parts = context.map { |ctx| ctx }
  vtysh_cmd(*parts, command)
end

.format_removal_command(cmd) ⇒ Object



251
252
253
# File 'lib/vtysh/diff.rb', line 251

def self.format_removal_command(cmd)
  vtysh_cmd("no #{cmd[:command]}")
end

.handle_bgp_recreation(source_cmds, target_cmds) ⇒ Object



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/vtysh/diff.rb', line 72

def self.handle_bgp_recreation(source_cmds, target_cmds)
  source_asns = source_cmds.select { |c| c[:command].start_with?("router bgp") }.map { |c| c[:command].split[2] }
  target_asns = target_cmds.select { |c| c[:command].start_with?("router bgp") }.map { |c| c[:command].split[2] }

  commands = []

  (source_asns & target_asns).each do |asn|
    bgp_ctx = "router bgp #{asn}"

    # Step 1: Remove the old BGP block
    commands << vtysh_cmd("no #{bgp_ctx}")

    # Step 2: Create the new BGP block
    commands << vtysh_cmd(bgp_ctx)

    # Step 3: Add main BGP commands (not inside address-family)
    target_bgp = target_cmds.select { |c|
      c[:depth] > 0 &&
      c[:context].include?(bgp_ctx) &&
      !c[:context].any? { |ctx| ctx.start_with?("address-family") } &&
      !is_block_command(c[:command])  # skip block commands (router bgp, address-family)
    }

    target_bgp.sort_by { |c| bgp_command_priority(c[:command]) }.each do |cmd|
      commands << vtysh_cmd(bgp_ctx, cmd[:command])
    end

    # Step 4: Add address-family blocks
    af_contexts = target_cmds.select { |c|
      c[:command].start_with?("address-family") && c[:context].include?(bgp_ctx)
    }.map { |c| c[:command] }.uniq

    af_contexts.each do |af|
      commands << vtysh_cmd(bgp_ctx, af)

      af_cmds = target_cmds.select { |c|
        c[:depth] > 0 &&
        c[:context].include?(bgp_ctx) &&
        c[:context].include?(af) &&
        c[:command] != af  # skip the address-family command itself
      }

      af_cmds.each do |cmd|
        commands << vtysh_cmd(bgp_ctx, af, cmd[:command])
      end
    end
  end

  commands
end

.handle_incremental_bgp_changes(source_cmds, target_cmds) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/vtysh/diff.rb', line 51

def self.handle_incremental_bgp_changes(source_cmds, target_cmds)
  commands = []

  # BGP removals
  (source_cmds - target_cmds).each do |cmd|
    next unless inside_bgp?(cmd)
    next if cmd[:command].start_with?("router bgp") # don't remove the block itself
    next if block_being_removed?(cmd, source_cmds, target_cmds)
    commands << format_context_command("no #{cmd[:command]}", cmd[:context])
  end

  # BGP additions
  (target_cmds - source_cmds).each do |cmd|
    next unless inside_bgp?(cmd)
    next if cmd[:command].start_with?("router bgp")
    commands << format_context_command(cmd[:command], cmd[:context])
  end

  reorder_bgp(commands)
end

.handle_non_bgp_changes(source_cmds, target_cmds, skip_bgp_blocks: false) ⇒ Object



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/vtysh/diff.rb', line 19

def self.handle_non_bgp_changes(source_cmds, target_cmds, skip_bgp_blocks: false)
  commands = []

  # Removals (non-BGP, plus top-level BGP block removals when not recreating)
  (source_cmds - target_cmds).each do |cmd|
    next if inside_bgp?(cmd) && !cmd[:command].start_with?("router bgp")
    next if skip_bgp_blocks && cmd[:command].start_with?("router bgp")
    if is_block_command(cmd[:command])
      commands << format_removal_command(cmd)
    elsif cmd[:depth] > 0
      next if block_being_removed?(cmd, source_cmds, target_cmds)
      commands << format_context_command("no #{cmd[:command]}", cmd[:context])
    else
      commands << vtysh_cmd("no #{cmd[:command]}")
    end
  end

  # Non-BGP additions
  (target_cmds - source_cmds).each do |cmd|
    next if inside_bgp?(cmd)
    if is_block_command(cmd[:command])
      commands << vtysh_cmd(cmd[:command])
    elsif cmd[:depth] > 0
      commands << format_context_command(cmd[:command], cmd[:context])
    else
      commands << vtysh_cmd(cmd[:command])
    end
  end

  reorder_non_bgp(commands)
end

.inside_bgp?(cmd) ⇒ Boolean

--- Helpers ---

Returns:

  • (Boolean)


125
126
127
128
# File 'lib/vtysh/diff.rb', line 125

def self.inside_bgp?(cmd)
  cmd[:context].any? { |ctx| ctx.start_with?("router bgp") } ||
    cmd[:command].start_with?("router bgp")
end

.is_block_command(cmd) ⇒ Object



240
241
242
# File 'lib/vtysh/diff.rb', line 240

def self.is_block_command(cmd)
  cmd.start_with?('router ', 'interface ', 'route-map ', 'vrf ', 'address-family ')
end

.needs_bgp_recreation?(source_cmds, target_cmds) ⇒ Boolean

Returns:

  • (Boolean)


137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/vtysh/diff.rb', line 137

def self.needs_bgp_recreation?(source_cmds, target_cmds)
  source_asns = source_cmds.select { |c| c[:command].start_with?("router bgp") }.map { |c| c[:command].split[2] }
  target_asns = target_cmds.select { |c| c[:command].start_with?("router bgp") }.map { |c| c[:command].split[2] }

  return false if source_asns != target_asns || source_asns.empty?

  source_asns.any? do |asn|
    src_rid = source_cmds.find { |c| c[:command].include?("bgp router-id") && c[:context].any? { |ctx| ctx.include?("router bgp #{asn}") } }
    tgt_rid = target_cmds.find { |c| c[:command].include?("bgp router-id") && c[:context].any? { |ctx| ctx.include?("router bgp #{asn}") } }
    src_rid && tgt_rid && src_rid[:command] != tgt_rid[:command]
  end
end

.parse_config(config) ⇒ Object

--- Parsing ---



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/vtysh/diff.rb', line 210

def self.parse_config(config)
  commands = []
  context_stack = []

  config.each_line do |line|
    line = line.strip
    next if line.empty? || line.start_with?('#', '!')
    next if line.start_with?('ip route ')       # managed by config_db STATIC_ROUTE
    next if line.start_with?('hostname ')      # managed by system hostname
    next if line == 'ip nht resolve-via-default'   # bgpcfgd managed
    next if line == 'ipv6 nht resolve-via-default' # bgpcfgd managed
    next if line == 'ip protocol bgp route-map RM_SET_SRC' # bgpcfgd managed
    next if line =~ /^(no )?set src /                      # bgpcfgd managed (inside RM_SET_SRC)
    next if line =~ /^frr (version|defaults)/ || line == 'no service integrated-vtysh-config' || line.start_with?('agentx')

    if line =~ /^exit(-address-family|-vrf)?$/
      context_stack.pop unless context_stack.empty?
    elsif line == 'end'
      context_stack = []
    elsif is_block_command(line)
      context_stack << line
      commands << { command: line, context: context_stack.dup, depth: context_stack.size }
    else
      commands << { command: line, context: context_stack.dup, depth: context_stack.size }
    end
  end

  commands
end

.reorder_bgp(commands) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/vtysh/diff.rb', line 183

def self.reorder_bgp(commands)
  peer_groups = commands.select { |c| c =~ /neighbor \S+ peer-group"$/ && !c.include?("no ") }
  remote_as = commands.select { |c| c.include?("remote-as") && !c.include?("no ") }
  listen_range = commands.select { |c| c.include?("bgp listen range") && !c.include?("no ") }
  removals = commands.select { |c| c.include?("no ") }
  rest = commands - peer_groups - remote_as - listen_range - removals

  # A removal whose slot is reclaimed by an addition must run first, or FRR
  # rejects the addition as overlapping the value it supersedes.
  slots = (commands - removals).map { |c| slot_key(c) }.compact
  superseded, other = removals.partition { |c| slots.include?(slot_key(c)) }

  (peer_groups + remote_as + superseded + listen_range + rest + other).uniq
end

.reorder_non_bgp(commands) ⇒ Object



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/vtysh/diff.rb', line 161

def self.reorder_non_bgp(commands)
  prefix_lists = commands.select { |c| c.include?("ip prefix-list") }
  # Only match route-map block creation (2 -c args), not commands inside route-maps
  route_maps = commands.select { |c| c.include?("route-map") && !c.include?("no ") && c.scan(/-c "/).count == 2 }
  top_level_removals = commands.select { |c| c.include?("no ") && c.scan(/-c "/).count <= 2 } - prefix_lists
  rest = commands - prefix_lists - route_maps - top_level_removals

  # Within context: group by context path, put removals before additions
  # so FRR replaces set/match statements correctly (remove old, then add new)
  rest = rest.group_by { |c|
    parts = c.scan(/-c "([^"]+)"/).flatten
    parts[0..-2].join("|")  # context = all but last -c arg
  }.flat_map { |_ctx, cmds|
    cmds.sort_by { |c|
      last_arg = c.scan(/-c "([^"]+)"/).flatten.last
      last_arg&.start_with?("no ") ? 0 : 1
    }
  }

  (prefix_lists + route_maps + rest + top_level_removals).uniq
end

.slot_key(command) ⇒ Object

Identifies commands that occupy one logical slot, so the "no" form and the form replacing it hash alike. Nil when the command owns no exclusive slot.



200
201
202
203
204
205
206
# File 'lib/vtysh/diff.rb', line 200

def self.slot_key(command)
  parts = command.scan(/-c "([^"]+)"/).flatten
  slot = case parts.last.to_s.sub(/\Ano /, "")
         when /\Abgp listen range \S+ peer-group (\S+)\z/ then "listen-range:#{$1}"
         end
  slot && (parts[0..-2] + [slot]).join("|")
end

.vtysh_cmd(*parts) ⇒ Object

--- Formatting ---



246
247
248
249
# File 'lib/vtysh/diff.rb', line 246

def self.vtysh_cmd(*parts)
  args = ["configure"] + parts
  "vtysh " + args.map { |p| "-c \"#{p}\"" }.join(" ")
end