Class: Mt::Wall::DesiredState

Inherits:
Object
  • Object
show all
Defined in:
lib/mt/wall/desired_state.rb

Overview

A normalized set of RouterOS resources, keyed by resource path, e.g.:

{
  "/ip/firewall/address-list" => [{ list: "web", address: "10.0.0.5" }, ...],
  "/ip/firewall/filter"       => [{ chain: "forward", action: "accept", ... }, ...]
}

Both the compiled DESIRED state (from the Compiler) and the fetched CURRENT state (from a Transport) use this exact shape, so Plan.diff can compare them path-by-path without knowing about transports or the DSL.

OWNERSHIP IS PER-TABLE, NOT UNIFORM:

* FILTER + NAT tables (/ip/firewall/filter, /ipv6/firewall/filter,
  /ip/firewall/nat) are owned WHOLESALE — apply replaces each table
  entirely, including RouterOS default-config rows.
* ADDRESS-LIST tables (/ip/firewall/address-list,
  /ipv6/firewall/address-list) are owned ONLY for the LIST NAMES the
  Compiler emits from hosts/groups (see Compiler.managed_list_names).
  Foreign/static lists (operator- or script-maintained, not emitted by
  mt-wall) are NEVER touched or deleted. The diff for address-lists is
  scoped to managed names and keyed by the NATURAL KEY `(list, address)`
  — not the device `.id`.

NAT is IPv4-only in v1 — there is no /ipv6/firewall/nat key. The slash- delimited path is also the REST URL suffix the transport maps to (‘/rest/ip/firewall/filter`, `/rest/ipv6/firewall/filter`, …).

IDENTITY & DIFF CONTRACT (see Plan.diff): filter/nat rows are matched desired<->current by the ‘(tag, ordinal)` key built from their content-only `mt-wall:<stable-hash>` identity tag (in `comment`), NEVER by the opaque device `.id`; address-list rows match by `(list, address)`. Before diffing, BOTH sides must be normalized: RouterOS returns booleans/numbers as STRINGS (“true”/“yes”/“443”), so values are coerced consistently on both sides; and rows flagged `dynamic` (RouterOS-generated, not user-managed) are EXCLUDED from the current state so they are never diffed or deleted.

KEY CONVENTION: rows use Symbol keys in Ruby-idiomatic underscore form (‘:src_address_list`, `:connection_state`, `:dst_port`). A transport is responsible for mapping those to/from the RouterOS hyphenated REST keys (`src-address-list`, …) before handing rows to DesiredState.from_current, so both sides of the diff share one shape.

Constant Summary collapse

IPV4_PATHS =

Managed IPv4 table paths.

[
  "/ip/firewall/address-list",
  "/ip/firewall/filter",
  "/ip/firewall/nat"
].freeze
IPV6_PATHS =

Managed IPv6 table paths (no NAT in v1).

[
  "/ipv6/firewall/address-list",
  "/ipv6/firewall/filter"
].freeze
MANAGED_PATHS =

Every path mt-wall reads/owns; the default set Transport#fetch reads.

(IPV4_PATHS + IPV6_PATHS).freeze
FULL_TABLE_PATHS =

Tables owned WHOLESALE (apply replaces them in full). Address-list paths are deliberately EXCLUDED — they are managed-name-scoped, not wholesale.

[
  "/ip/firewall/filter",
  "/ip/firewall/nat",
  "/ipv6/firewall/filter"
].freeze
ADDRESS_LIST_PATHS =

Address-list paths owned only for the Compiler-emitted list names; rows diff by the natural key ‘(list, address)`.

[
  "/ip/firewall/address-list",
  "/ipv6/firewall/address-list"
].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(resources = {}) ⇒ DesiredState

Returns a new instance of DesiredState.



81
82
83
84
85
86
# File 'lib/mt/wall/desired_state.rb', line 81

def initialize(resources = {})
  @resources = {}
  resources.each do |path, rows|
    Array(rows).each { |row| add(path, row) }
  end
end

Instance Attribute Details

#resourcesObject (readonly)

Returns the value of attribute resources.



79
80
81
# File 'lib/mt/wall/desired_state.rb', line 79

def resources
  @resources
end

Class Method Details

.canonical_address(value) ⇒ String

Canonical address form: a single host is rendered BARE (redundant ‘/32` or `/128` stripped — this is what RouterOS returns for v4 hosts, so both sides agree); a CIDR subnet is rendered as its normalized `network/prefix` (IPAddr masks host bits and compresses IPv6). Values IPAddr cannot parse — RANGES like `10.0.0.1-10.0.0.10` or any other non-IP form — are left UNTOUCHED so they are never mangled or made to raise.

Returns:

  • (String)


150
151
152
153
154
155
156
# File 'lib/mt/wall/desired_state.rb', line 150

def self.canonical_address(value)
  ip = IPAddr.new(value)
  host_prefix = ip.ipv4? ? 32 : 128
  ip.prefix == host_prefix ? ip.to_s : "#{ip}/#{ip.prefix}"
rescue IPAddr::Error
  value
end

.dynamic?(row) ⇒ Boolean

Returns whether a raw row is RouterOS-generated (dynamic).

Returns:

  • (Boolean)

    whether a raw row is RouterOS-generated (dynamic).



194
195
196
197
# File 'lib/mt/wall/desired_state.rb', line 194

def self.dynamic?(row)
  value = row[:dynamic] || row["dynamic"]
  value == true || %w[true yes 1].include?(value.to_s)
end

.from_current(raw_resources, managed_list_names: []) ⇒ DesiredState

Build a CURRENT-state DesiredState from a transport’s raw resource hash: values are string-coerced, ‘dynamic` rows are dropped (RouterOS-generated, never user-managed) and address-list rows are scoped to the managed list names so foreign/static lists are never diffed or deleted.

Parameters:

  • raw_resources (Hash{String => Array<Hash>})
  • managed_list_names (Array<String>) (defaults to: [])

Returns:



175
176
177
178
179
180
# File 'lib/mt/wall/desired_state.rb', line 175

def self.from_current(raw_resources, managed_list_names: [])
  managed = managed_list_names.map(&:to_s)
  raw_resources.each_with_object(new) do |(path, rows), state|
    Array(rows).each { |row| ingest_current(state, path, row, managed) }
  end
end

.normalize_address_list(path, row) ⇒ Hash

Canonicalize the ‘:address` of an address-list row so the desired and current sides compare equal regardless of which form RouterOS returns (it stores v4 hosts bare but v6 hosts as `/128`). Applied IDENTICALLY on both sides via #add, so the chosen form only has to be internally consistent. Non-address-list paths and rows without an `:address` are returned unchanged.

Returns:

  • (Hash)


137
138
139
140
141
# File 'lib/mt/wall/desired_state.rb', line 137

def self.normalize_address_list(path, row)
  return row unless ADDRESS_LIST_PATHS.include?(path) && row.key?(:address)

  row.merge(address: canonical_address(row[:address]))
end

.normalize_row(row) ⇒ Hash

Normalize every value in a row to its String form, dropping keys whose value normalizes to nil (absent / empty).

Returns:

  • (Hash)


161
162
163
164
165
166
# File 'lib/mt/wall/desired_state.rb', line 161

def self.normalize_row(row)
  row.each_with_object({}) do |(key, value), out|
    normalized = normalize_value(value)
    out[key] = normalized unless normalized.nil?
  end
end

.normalize_value(value) ⇒ String?

Coerce a single value to its normalized String form (or nil to drop the key). RouterOS REST renders booleans/numbers as strings, so the desired side is coerced the same way to stay diffable.

Returns:

  • (String, nil)


120
121
122
123
124
125
126
127
128
# File 'lib/mt/wall/desired_state.rb', line 120

def self.normalize_value(value)
  case value
  when nil   then nil
  when Range then "#{value.first}-#{value.last}"
  when Array
    value.empty? ? nil : value.map { |element| normalize_value(element) }.compact.join(",")
  else value.to_s # String/Symbol/Integer/true/false all render correctly
  end
end

Instance Method Details

#==(other) ⇒ Object Also known as: eql?



107
108
109
# File 'lib/mt/wall/desired_state.rb', line 107

def ==(other)
  other.is_a?(DesiredState) && resources == other.resources
end

#[](path) ⇒ Array<Hash>

Returns entries for that path (empty if none).

Parameters:

  • path (String)

    RouterOS resource path

Returns:

  • (Array<Hash>)

    entries for that path (empty if none)



98
99
100
# File 'lib/mt/wall/desired_state.rb', line 98

def [](path)
  @resources.fetch(path, [])
end

#add(path, row) ⇒ self

Append a row to a path, normalizing its values so the desired and current sides compare equal.

Returns:

  • (self)


91
92
93
94
# File 'lib/mt/wall/desired_state.rb', line 91

def add(path, row)
  (@resources[path] ||= []) << self.class.normalize_address_list(path, self.class.normalize_row(row))
  self
end

#hashObject



112
113
114
# File 'lib/mt/wall/desired_state.rb', line 112

def hash
  resources.hash
end

#pathsArray<String>

Returns paths that carry at least one row.

Returns:

  • (Array<String>)

    paths that carry at least one row



103
104
105
# File 'lib/mt/wall/desired_state.rb', line 103

def paths
  @resources.keys
end