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



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

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

.format_removal_command(cmd) ⇒ Object



236
237
238
# File 'lib/vtysh/diff.rb', line 236

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



225
226
227
# File 'lib/vtysh/diff.rb', line 225

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 —



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/vtysh/diff.rb', line 195

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
# 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

  (peer_groups + remote_as + listen_range + rest + removals).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

.vtysh_cmd(*parts) ⇒ Object

— Formatting —



231
232
233
234
# File 'lib/vtysh/diff.rb', line 231

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