opn_api 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.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ # Base error class for all opn_api exceptions.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a network connection cannot be established
8
+ # (e.g. ECONNREFUSED, EHOSTUNREACH, ETIMEDOUT).
9
+ class ConnectionError < Error; end
10
+
11
+ # Raised when an HTTP request times out (open_timeout or read_timeout).
12
+ class TimeoutError < Error; end
13
+
14
+ # Raised when the OPNsense API returns a non-2xx HTTP response.
15
+ class ApiError < Error
16
+ # @return [Integer] HTTP status code
17
+ attr_reader :code
18
+
19
+ # @return [String] HTTP response body
20
+ attr_reader :body
21
+
22
+ # @return [String] Request URI that caused the error
23
+ attr_reader :uri
24
+
25
+ # @param message [String]
26
+ # @param code [Integer] HTTP status code
27
+ # @param body [String] Response body
28
+ # @param uri [String] Request URI
29
+ def initialize(message, code:, body:, uri:)
30
+ @code = code
31
+ @body = body
32
+ @uri = uri
33
+ super(message)
34
+ end
35
+ end
36
+
37
+ # Raised when a configuration file is missing or malformed.
38
+ class ConfigError < Error; end
39
+
40
+ # Raised when IdResolver cannot translate a name to an ID or vice versa.
41
+ class ResolveError < Error; end
42
+
43
+ # Raised when a configtest returns ALERT (e.g. HAProxy configtest).
44
+ class ConfigTestError < Error; end
45
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ # Resolves ModelRelationField UUIDs/IDs <-> names for OPNsense resources.
5
+ #
6
+ # Maintains a per-run class-level cache keyed by
7
+ # "device:endpoint:id_field:name_field" to avoid redundant API calls.
8
+ #
9
+ # Supports:
10
+ # - Standard UUID fields (ModelRelationField) — id_field: 'uuid', name_field: 'name'
11
+ # - Certificate fields (CertificateField) — id_field: 'refid', name_field: 'descr'
12
+ # - Cron job fields — id_field: 'uuid', name_field: 'description'
13
+ # - Dot-path fields for nested configs — e.g. 'general.stats.allowedUsers'
14
+ #
15
+ # @example
16
+ # relation_fields = {
17
+ # 'linkedServers' => { endpoint: 'haproxy/settings/search_servers', multiple: true },
18
+ # 'sslCA' => { endpoint: 'trust/ca/search', id_field: 'refid', name_field: 'descr' },
19
+ # }
20
+ # translated = OpnApi::IdResolver.translate_to_names(client, 'myfw', relation_fields, config)
21
+ module IdResolver
22
+ UUID_RE = %r{\A[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}\z}
23
+
24
+ @cache = {}
25
+
26
+ # Queries the given endpoint once and builds id<->name maps.
27
+ # Subsequent calls for the same cache key are no-ops.
28
+ #
29
+ # @param client [OpnApi::Client]
30
+ # @param device [String]
31
+ # @param endpoint [String]
32
+ # @param id_field [String] Field containing the ID (default: 'uuid')
33
+ # @param name_field [String] Field containing the display name (default: 'name')
34
+ # @param method [Symbol] HTTP method to use (default: :post)
35
+ def self.populate(client, device, endpoint, id_field: 'uuid', name_field: 'name', method: :post)
36
+ key = cache_key(device, endpoint, id_field, name_field)
37
+ return if @cache.key?(key)
38
+
39
+ id_to_name = {}
40
+ name_to_id = {}
41
+ begin
42
+ response = method == :get ? client.get(endpoint) : client.post(endpoint, {})
43
+ rows = response['rows'] || []
44
+ rows.each do |row|
45
+ id = row[id_field].to_s
46
+ name = row[name_field].to_s
47
+ next if id.empty? || name.empty?
48
+
49
+ id_to_name[id] = name
50
+ name_to_id[name] = id
51
+ end
52
+ rescue OpnApi::Error => e
53
+ OpnApi.logger.warning(
54
+ "IdResolver: failed to populate '#{endpoint}' " \
55
+ "for '#{device}': #{e.message}",
56
+ )
57
+ end
58
+
59
+ @cache[key] = { id_to_name: id_to_name, name_to_id: name_to_id }
60
+ end
61
+
62
+ # Returns a deep copy of +config+ with each relation-field value translated
63
+ # from IDs to names. Falls back to the original value if not found.
64
+ # Supports dot-path field names for nested config hashes.
65
+ #
66
+ # @param client [OpnApi::Client]
67
+ # @param device [String]
68
+ # @param relation_fields [Hash] field_name => { endpoint:, multiple:, ... }
69
+ # @param config [Hash]
70
+ # @return [Hash]
71
+ def self.translate_to_names(client, device, relation_fields, config)
72
+ result = deep_dup(config)
73
+ relation_fields.each do |field, opts|
74
+ id_field = opts[:id_field] || 'uuid'
75
+ name_field = opts[:name_field] || 'name'
76
+ http_method = opts[:method] || :post
77
+
78
+ parent, last_key = dig_path(result, field)
79
+ next unless parent
80
+
81
+ value = parent[last_key]
82
+ next if value.nil? || value.to_s.empty?
83
+
84
+ populate(client, device, opts[:endpoint],
85
+ id_field: id_field, name_field: name_field, method: http_method)
86
+ entry = @cache[cache_key(device, opts[:endpoint], id_field, name_field)] || {}
87
+ map = entry[:id_to_name] || {}
88
+
89
+ parent[last_key] = if opts[:multiple]
90
+ value.to_s.split(',').map do |item|
91
+ item = item.strip
92
+ map[item] || item
93
+ end.join(',')
94
+ else
95
+ map[value.to_s] || value.to_s
96
+ end
97
+ end
98
+ result
99
+ end
100
+
101
+ # Returns a deep copy of +config+ with each relation-field value translated
102
+ # from names to IDs. Raises ResolveError if a name cannot be resolved.
103
+ # Values that are already valid IDs pass through unchanged.
104
+ # Supports dot-path field names for nested config hashes.
105
+ #
106
+ # @param client [OpnApi::Client]
107
+ # @param device [String]
108
+ # @param relation_fields [Hash] field_name => { endpoint:, multiple:, ... }
109
+ # @param config [Hash]
110
+ # @return [Hash]
111
+ def self.translate_to_uuids(client, device, relation_fields, config)
112
+ result = deep_dup(config)
113
+ relation_fields.each do |field, opts|
114
+ id_field = opts[:id_field] || 'uuid'
115
+ name_field = opts[:name_field] || 'name'
116
+ http_method = opts[:method] || :post
117
+
118
+ parent, last_key = dig_path(result, field)
119
+ next unless parent
120
+
121
+ value = parent[last_key]
122
+ next if value.nil? || value.to_s.empty?
123
+
124
+ key = cache_key(device, opts[:endpoint], id_field, name_field)
125
+ populate(client, device, opts[:endpoint],
126
+ id_field: id_field, name_field: name_field, method: http_method)
127
+ entry = @cache[key] || {}
128
+ id_to_name = entry[:id_to_name] || {}
129
+
130
+ parent[last_key] = if opts[:multiple]
131
+ value.to_s.split(',').map do |item|
132
+ item = item.strip
133
+ # Already a known ID — pass through
134
+ next item if id_to_name.key?(item)
135
+ # Looks like a UUID — pass through
136
+ next item if UUID_RE.match?(item)
137
+
138
+ resolve_with_retry(
139
+ client, device, opts[:endpoint],
140
+ id_field, name_field, http_method, item
141
+ )
142
+ end.join(',')
143
+ else
144
+ str = value.to_s
145
+ if id_to_name.key?(str) || UUID_RE.match?(str)
146
+ str
147
+ else
148
+ resolve_with_retry(
149
+ client, device, opts[:endpoint],
150
+ id_field, name_field, http_method, str
151
+ )
152
+ end
153
+ end
154
+ end
155
+ result
156
+ end
157
+
158
+ # Clears all cached mappings. Call between runs or in tests.
159
+ def self.reset!
160
+ @cache.clear
161
+ end
162
+
163
+ # Navigates a dotted field path in a hash and returns [parent, last_key].
164
+ # For 'stats.allowedUsers' on { 'stats' => { 'allowedUsers' => 'x' } }
165
+ # returns [{ 'allowedUsers' => 'x' }, 'allowedUsers'].
166
+ # For simple fields like 'defaultBackend', returns [hash, 'defaultBackend'].
167
+ # Returns [nil, nil] if any intermediate key is missing.
168
+ def self.dig_path(hash, dotted_field)
169
+ parts = dotted_field.split('.')
170
+ parent = hash
171
+ parts[0..-2].each do |part|
172
+ parent = parent[part]
173
+ return [nil, nil] unless parent.is_a?(Hash)
174
+ end
175
+ [parent, parts.last]
176
+ end
177
+
178
+ # Deep-duplicates a Hash/Array structure.
179
+ def self.deep_dup(obj)
180
+ case obj
181
+ when Hash
182
+ obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
183
+ when Array
184
+ obj.map { |v| deep_dup(v) }
185
+ else
186
+ obj
187
+ end
188
+ end
189
+
190
+ # Builds a cache key from device, endpoint, id_field, and name_field.
191
+ def self.cache_key(device, endpoint, id_field, name_field)
192
+ "#{device}:#{endpoint}:#{id_field}:#{name_field}"
193
+ end
194
+ private_class_method :cache_key
195
+
196
+ # Attempts to resolve a name to an ID, retrying once with a cache refresh.
197
+ # Raises ResolveError if the name cannot be found after retry.
198
+ def self.resolve_with_retry(client, device, endpoint, id_field, name_field, http_method, name)
199
+ key = cache_key(device, endpoint, id_field, name_field)
200
+ entry = @cache[key] || {}
201
+ name_to_id = entry[:name_to_id] || {}
202
+
203
+ resolved = name_to_id[name]
204
+ unless resolved
205
+ # Cache miss — refresh and try once more
206
+ @cache.delete(key)
207
+ populate(client, device, endpoint,
208
+ id_field: id_field, name_field: name_field, method: http_method)
209
+ entry = @cache[key] || {}
210
+ name_to_id = entry[:name_to_id] || {}
211
+ resolved = name_to_id[name]
212
+ end
213
+ unless resolved
214
+ raise OpnApi::ResolveError,
215
+ "IdResolver: cannot resolve '#{name}' to an ID " \
216
+ "via '#{endpoint}' on '#{device}'"
217
+ end
218
+ resolved
219
+ end
220
+ private_class_method :resolve_with_retry
221
+ end
222
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ # Pluggable logger with five severity levels.
5
+ #
6
+ # Default: writes to $stderr at :info level. For puppet-opn integration,
7
+ # replace with a logger that delegates to Puppet.debug/notice/warning/err.
8
+ #
9
+ # @example Custom logger for Puppet integration
10
+ # OpnApi.logger = PuppetLogger.new
11
+ class Logger
12
+ LEVELS = %i[debug info notice warning error].freeze
13
+
14
+ # @param output [IO] Output stream (default: $stderr)
15
+ # @param level [Symbol] Minimum severity to log (default: :info)
16
+ def initialize(output: $stderr, level: :info)
17
+ @output = output
18
+ @level = LEVELS.index(level) || 1
19
+ end
20
+
21
+ LEVELS.each_with_index do |name, idx|
22
+ define_method(name) do |msg|
23
+ return if idx < @level
24
+
25
+ @output.puts("[opn_api] #{name.upcase}: #{msg}")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ # Normalization of OPNsense selection hashes.
5
+ #
6
+ # OPNsense returns multi-select/dropdown fields as hashes like:
7
+ # { "opt1" => { "value" => "Option 1", "selected" => 1 },
8
+ # "opt2" => { "value" => "Option 2", "selected" => 0 } }
9
+ #
10
+ # This module collapses them to comma-separated strings of selected
11
+ # keys (e.g. "opt1") and recurses into nested hashes.
12
+ module Normalize
13
+ module_function
14
+
15
+ # Recursively normalizes OPNsense selection hashes to simple values.
16
+ #
17
+ # @param obj [Object] The value to normalize
18
+ # @return [Object] Normalized value
19
+ def normalize_config(obj)
20
+ return obj unless obj.is_a?(Hash)
21
+ return normalize_selection(obj) if selection_hash?(obj)
22
+
23
+ obj.transform_values { |v| normalize_config(v) }
24
+ end
25
+
26
+ # Detects whether a hash is an OPNsense selection hash.
27
+ #
28
+ # A selection hash has non-empty entries where every value is a Hash
29
+ # containing at least 'value' and 'selected' keys.
30
+ #
31
+ # @param hash [Hash]
32
+ # @return [Boolean]
33
+ def selection_hash?(hash)
34
+ hash.is_a?(Hash) &&
35
+ !hash.empty? &&
36
+ hash.values.all? { |v| v.is_a?(Hash) && v.key?('value') && v.key?('selected') }
37
+ end
38
+
39
+ # Collapses a selection hash to a comma-separated string of selected keys.
40
+ #
41
+ # @param hash [Hash] A selection hash (as detected by selection_hash?)
42
+ # @return [String] Comma-separated selected keys
43
+ def normalize_selection(hash)
44
+ hash.select { |_k, v| v['selected'].to_i == 1 }.keys.join(',')
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ # Generic CRUD wrapper for OPNsense API resources.
5
+ #
6
+ # Supports three resource patterns:
7
+ #
8
+ # 1. Standard CRUD (search/get/add/set/del with UUID)
9
+ # 2. Singleton settings (GET get / POST set, no UUID, no search/add/del)
10
+ # 3. Resources with GET-based search (e.g. snapshots, trust_crl)
11
+ #
12
+ # @example Via ResourceRegistry (preferred)
13
+ # res = OpnApi::ResourceRegistry.build(client, 'haproxy_server')
14
+ # res.search
15
+ #
16
+ # @example Direct with explicit paths
17
+ # res = OpnApi::Resource.new(
18
+ # client: client,
19
+ # base_path: 'haproxy/settings',
20
+ # search_action: 'search_servers',
21
+ # crud_action: '%{action}_server',
22
+ # )
23
+ #
24
+ # @example Singleton settings resource
25
+ # res = OpnApi::Resource.new(
26
+ # client: client,
27
+ # base_path: 'zabbixagent/settings',
28
+ # search_action: 'get',
29
+ # crud_action: '%{action}',
30
+ # singleton: true,
31
+ # search_method: :get,
32
+ # )
33
+ # config = res.show_settings # GET zabbixagent/settings/get
34
+ # res.update_settings(config) # POST zabbixagent/settings/set
35
+ class Resource
36
+ attr_reader :singleton
37
+
38
+ # @param client [OpnApi::Client] API client instance
39
+ # @param base_path [String] Base path (e.g. 'haproxy/settings')
40
+ # @param search_action [String] Search action name (e.g. 'search_servers')
41
+ # @param crud_action [String] CRUD action template with %{action} placeholder
42
+ # @param singleton [Boolean] True for settings resources (no UUID, no search/add/del)
43
+ # @param search_method [Symbol] HTTP method for search (:post or :get)
44
+ # @param module_name [String] Legacy: OPNsense module (e.g. 'firewall')
45
+ # @param controller [String] Legacy: OPNsense controller (e.g. 'alias')
46
+ # @param resource_type [String] Legacy: Resource type (e.g. 'item')
47
+ def initialize(client:, base_path: nil, search_action: nil, crud_action: nil,
48
+ singleton: false, search_method: :post,
49
+ module_name: nil, controller: nil, resource_type: nil)
50
+ @client = client
51
+ @singleton = singleton
52
+ @search_method = search_method
53
+
54
+ if base_path
55
+ # Explicit path mode
56
+ @base_path = base_path
57
+ @search_action = search_action
58
+ @crud_action = crud_action
59
+ else
60
+ # Legacy module/controller/type mode
61
+ @base_path = "#{module_name}/#{controller}"
62
+ suffix = resource_type.to_s.empty? ? '' : "_#{resource_type}"
63
+ @search_action = "search#{suffix}"
64
+ @crud_action = "%{action}#{suffix}"
65
+ end
66
+ end
67
+
68
+ # Searches for resources matching the given parameters.
69
+ #
70
+ # @param params [Hash] Search parameters (passed as POST body, ignored for GET)
71
+ # @return [Object] Search results (Array<Hash> for standard, Hash for singletons)
72
+ def search(params = {})
73
+ path = "#{@base_path}/#{@search_action}"
74
+ result = if @search_method == :get
75
+ @client.get(path)
76
+ else
77
+ @client.post(path, params)
78
+ end
79
+ # Singletons return the full response hash (settings structure).
80
+ # All other searches extract the rows array.
81
+ return result if @singleton
82
+
83
+ result.is_a?(Hash) ? (result['rows'] || []) : result
84
+ end
85
+
86
+ # Retrieves a single resource by UUID.
87
+ #
88
+ # @param uuid [String]
89
+ # @return [Hash] Resource data
90
+ def get(uuid)
91
+ @client.get("#{@base_path}/#{crud_path('get')}/#{uuid}")
92
+ end
93
+
94
+ # Retrieves a singleton settings resource (no UUID).
95
+ #
96
+ # @return [Hash] Resource data
97
+ def show_settings
98
+ @client.get("#{@base_path}/#{crud_path('get')}")
99
+ end
100
+
101
+ # Creates a new resource.
102
+ #
103
+ # @param config [Hash] Resource configuration (wrapped in wrapper key)
104
+ # @return [Hash] API response (typically includes 'uuid' on success)
105
+ def add(config)
106
+ @client.post("#{@base_path}/#{crud_path('add')}", config)
107
+ end
108
+
109
+ # Updates an existing resource.
110
+ #
111
+ # @param uuid [String]
112
+ # @param config [Hash] Resource configuration
113
+ # @return [Hash] API response
114
+ def set(uuid, config)
115
+ @client.post("#{@base_path}/#{crud_path('set')}/#{uuid}", config)
116
+ end
117
+
118
+ # Updates a singleton settings resource (no UUID).
119
+ #
120
+ # @param config [Hash] Resource configuration
121
+ # @return [Hash] API response
122
+ def update_settings(config)
123
+ @client.post("#{@base_path}/#{crud_path('set')}", config)
124
+ end
125
+
126
+ # Deletes a resource by UUID.
127
+ #
128
+ # @param uuid [String]
129
+ # @return [Hash] API response
130
+ def del(uuid)
131
+ @client.post("#{@base_path}/#{crud_path('del')}/#{uuid}", {})
132
+ end
133
+
134
+ private
135
+
136
+ # Builds a CRUD action path from the template.
137
+ # e.g. crud_path('get') with template '%{action}_server' → 'get_server'
138
+ def crud_path(action)
139
+ format(@crud_action, action: action)
140
+ end
141
+ end
142
+ end