Class: Mt::Wall::DesiredState
- Inherits:
-
Object
- Object
- Mt::Wall::DesiredState
- 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
-
#resources ⇒ Object
readonly
Returns the value of attribute resources.
Class Method Summary collapse
-
.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).
-
.dynamic?(row) ⇒ Boolean
Whether a raw row is RouterOS-generated (dynamic).
-
.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.
-
.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`).
-
.normalize_row(row) ⇒ Hash
Normalize every value in a row to its String form, dropping keys whose value normalizes to nil (absent / empty).
-
.normalize_value(value) ⇒ String?
Coerce a single value to its normalized String form (or nil to drop the key).
Instance Method Summary collapse
- #==(other) ⇒ Object (also: #eql?)
-
#[](path) ⇒ Array<Hash>
Entries for that path (empty if none).
-
#add(path, row) ⇒ self
Append a row to a path, normalizing its values so the desired and current sides compare equal.
- #hash ⇒ Object
-
#initialize(resources = {}) ⇒ DesiredState
constructor
A new instance of DesiredState.
-
#paths ⇒ Array<String>
Paths that carry at least one row.
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
#resources ⇒ Object (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.
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).
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.
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.
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).
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.
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).
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.
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 |
#hash ⇒ Object
112 113 114 |
# File 'lib/mt/wall/desired_state.rb', line 112 def hash resources.hash end |
#paths ⇒ Array<String>
Returns paths that carry at least one row.
103 104 105 |
# File 'lib/mt/wall/desired_state.rb', line 103 def paths @resources.keys end |