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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +25 -0
- data/README.md +979 -0
- data/bin/opn-api +7 -0
- data/lib/opn_api/cli/commands/api.rb +57 -0
- data/lib/opn_api/cli/commands/backup.rb +38 -0
- data/lib/opn_api/cli/commands/base.rb +50 -0
- data/lib/opn_api/cli/commands/device.rb +53 -0
- data/lib/opn_api/cli/commands/plugin.rb +67 -0
- data/lib/opn_api/cli/commands/reconfigure.rb +40 -0
- data/lib/opn_api/cli/commands/resource.rb +248 -0
- data/lib/opn_api/cli/formatter.rb +198 -0
- data/lib/opn_api/cli/main.rb +160 -0
- data/lib/opn_api/client.rb +204 -0
- data/lib/opn_api/config.rb +111 -0
- data/lib/opn_api/errors.rb +45 -0
- data/lib/opn_api/id_resolver.rb +222 -0
- data/lib/opn_api/logger.rb +29 -0
- data/lib/opn_api/normalize.rb +47 -0
- data/lib/opn_api/resource.rb +142 -0
- data/lib/opn_api/resource_registry.rb +377 -0
- data/lib/opn_api/service_reconfigure.rb +293 -0
- data/lib/opn_api/version.rb +5 -0
- data/lib/opn_api.rb +32 -0
- metadata +73 -0
data/bin/opn-api
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module OpnApi
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
# CLI commands for generic API calls (GET/POST).
|
|
9
|
+
module Api
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Performs a GET request to an arbitrary API path.
|
|
13
|
+
#
|
|
14
|
+
# @param args [Array<String>] [path]
|
|
15
|
+
# @param opts [Hash] Global CLI options
|
|
16
|
+
# @return [Object] Parsed API response
|
|
17
|
+
def get(args, opts)
|
|
18
|
+
path = args.shift
|
|
19
|
+
raise OpnApi::Error, 'Usage: opn-api get <path>' unless path
|
|
20
|
+
|
|
21
|
+
client = Base.build_client(opts)
|
|
22
|
+
client.get(path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Performs a POST request to an arbitrary API path.
|
|
26
|
+
#
|
|
27
|
+
# @param args [Array<String>] [path, optional_json]
|
|
28
|
+
# @param opts [Hash] Global CLI options
|
|
29
|
+
# @return [Object] Parsed API response
|
|
30
|
+
def post(args, opts)
|
|
31
|
+
path = args.shift
|
|
32
|
+
raise OpnApi::Error, 'Usage: opn-api post <path> [json]' unless path
|
|
33
|
+
|
|
34
|
+
client = Base.build_client(opts)
|
|
35
|
+
data = parse_post_data(args)
|
|
36
|
+
client.post(path, data)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parses POST data from args or stdin.
|
|
40
|
+
def parse_post_data(args)
|
|
41
|
+
# Inline JSON argument
|
|
42
|
+
json_str = args.shift
|
|
43
|
+
return JSON.parse(json_str) if json_str
|
|
44
|
+
|
|
45
|
+
# Stdin
|
|
46
|
+
unless $stdin.tty?
|
|
47
|
+
input = $stdin.read.strip
|
|
48
|
+
return JSON.parse(input) unless input.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Default: empty body
|
|
52
|
+
{}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpnApi
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# CLI command for downloading OPNsense config backups.
|
|
7
|
+
#
|
|
8
|
+
# The backup endpoint returns XML (not JSON), so this uses
|
|
9
|
+
# the client's raw mode to skip JSON parsing.
|
|
10
|
+
module Backup
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Downloads the OPNsense config backup (XML).
|
|
14
|
+
# Usage: opn-api backup [output_path]
|
|
15
|
+
#
|
|
16
|
+
# With output_path: writes XML to file, returns status hash.
|
|
17
|
+
# Without output_path: returns raw XML string (printed to stdout).
|
|
18
|
+
#
|
|
19
|
+
# @param args [Array<String>] [optional output_path]
|
|
20
|
+
# @param opts [Hash] Global CLI options
|
|
21
|
+
# @return [Hash, String] Status hash or raw XML data
|
|
22
|
+
def download(args, opts)
|
|
23
|
+
client = Base.build_client(opts)
|
|
24
|
+
data = client.get('core/backup/download/this', raw: true)
|
|
25
|
+
|
|
26
|
+
output_path = args.shift
|
|
27
|
+
if output_path
|
|
28
|
+
File.write(output_path, data)
|
|
29
|
+
{ 'status' => 'ok', 'file' => output_path, 'size' => data.bytesize }
|
|
30
|
+
else
|
|
31
|
+
# Without output path: raw data directly to stdout
|
|
32
|
+
data
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpnApi
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Shared helpers for all CLI commands.
|
|
7
|
+
module Base
|
|
8
|
+
# Creates an OpnApi::Config from global options.
|
|
9
|
+
#
|
|
10
|
+
# @param opts [Hash] Global CLI options
|
|
11
|
+
# @return [OpnApi::Config]
|
|
12
|
+
def self.build_config(opts)
|
|
13
|
+
OpnApi::Config.new(config_dir: opts[:config_dir])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Creates an OpnApi::Client for the device specified in global options.
|
|
17
|
+
#
|
|
18
|
+
# @param opts [Hash] Global CLI options (must include :device)
|
|
19
|
+
# @return [OpnApi::Client]
|
|
20
|
+
def self.build_client(opts)
|
|
21
|
+
config = build_config(opts)
|
|
22
|
+
config.client_for(opts[:device])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reads JSON input from -j argument or stdin.
|
|
26
|
+
#
|
|
27
|
+
# @param args [Array<String>] Remaining CLI args
|
|
28
|
+
# @return [Hash] Parsed JSON
|
|
29
|
+
def self.read_json_input(args)
|
|
30
|
+
# Check for -j flag in remaining args
|
|
31
|
+
json_idx = args.index('-j')
|
|
32
|
+
if json_idx
|
|
33
|
+
json_str = args[json_idx + 1]
|
|
34
|
+
raise OpnApi::Error, 'Missing JSON data after -j' unless json_str
|
|
35
|
+
|
|
36
|
+
return JSON.parse(json_str)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read from stdin if not a TTY
|
|
40
|
+
unless $stdin.tty?
|
|
41
|
+
input = $stdin.read.strip
|
|
42
|
+
return JSON.parse(input) unless input.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
raise OpnApi::Error, 'No JSON input provided (use -j or pipe via stdin)'
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpnApi
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# CLI commands for device management.
|
|
7
|
+
module Device
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Lists all configured devices.
|
|
11
|
+
#
|
|
12
|
+
# @param _args [Array<String>] Unused
|
|
13
|
+
# @param opts [Hash] Global CLI options
|
|
14
|
+
# @return [Object] Formatted output data
|
|
15
|
+
def list(_args, opts)
|
|
16
|
+
config = Base.build_config(opts)
|
|
17
|
+
names = config.device_names
|
|
18
|
+
return '(no devices configured)' if names.empty?
|
|
19
|
+
|
|
20
|
+
names.map { |n| { 'device' => n, 'config' => config.device_path(n) } }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Tests connectivity to one or all devices.
|
|
24
|
+
#
|
|
25
|
+
# @param args [Array<String>] Optional device name
|
|
26
|
+
# @param opts [Hash] Global CLI options
|
|
27
|
+
# @return [Object] Formatted output data
|
|
28
|
+
def test(args, opts)
|
|
29
|
+
config = Base.build_config(opts)
|
|
30
|
+
names = args.empty? ? config.device_names : [args.first]
|
|
31
|
+
results = []
|
|
32
|
+
|
|
33
|
+
names.each do |name|
|
|
34
|
+
result = { 'device' => name }
|
|
35
|
+
begin
|
|
36
|
+
client = config.client_for(name)
|
|
37
|
+
# Call a lightweight endpoint to check connectivity
|
|
38
|
+
info = client.get('core/firmware/info')
|
|
39
|
+
result['status'] = 'ok'
|
|
40
|
+
result['version'] = info['product_version'].to_s if info['product_version']
|
|
41
|
+
rescue OpnApi::Error => e
|
|
42
|
+
result['status'] = 'error'
|
|
43
|
+
result['message'] = e.message
|
|
44
|
+
end
|
|
45
|
+
results << result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
results
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpnApi
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# CLI commands for OPNsense plugin management.
|
|
7
|
+
#
|
|
8
|
+
# Plugins use the firmware API which does not follow the standard
|
|
9
|
+
# CRUD pattern. These commands provide a user-friendly interface.
|
|
10
|
+
module Plugin
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Lists installed plugins.
|
|
14
|
+
# Usage: opn-api plugins
|
|
15
|
+
#
|
|
16
|
+
# Fetches the firmware info and filters for installed os-* packages.
|
|
17
|
+
#
|
|
18
|
+
# @param _args [Array<String>] Unused
|
|
19
|
+
# @param opts [Hash] Global CLI options
|
|
20
|
+
# @return [Array<Hash>] List of installed plugins
|
|
21
|
+
def list(_args, opts)
|
|
22
|
+
client = Base.build_client(opts)
|
|
23
|
+
info = client.get('core/firmware/info')
|
|
24
|
+
packages = info['package'] || []
|
|
25
|
+
|
|
26
|
+
# Filter for installed plugin packages (os-* naming convention)
|
|
27
|
+
packages.select { |p| p['installed'] == '1' && p['name'].to_s.start_with?('os-') }
|
|
28
|
+
.map do |p|
|
|
29
|
+
{
|
|
30
|
+
'name' => p['name'],
|
|
31
|
+
'version' => p['version'],
|
|
32
|
+
'comment' => p['comment'],
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Installs a plugin.
|
|
38
|
+
# Usage: opn-api install <plugin_name>
|
|
39
|
+
#
|
|
40
|
+
# @param args [Array<String>] [plugin_name]
|
|
41
|
+
# @param opts [Hash] Global CLI options
|
|
42
|
+
# @return [Hash] API response
|
|
43
|
+
def install(args, opts)
|
|
44
|
+
name = args.shift
|
|
45
|
+
raise OpnApi::Error, 'Usage: opn-api install <plugin_name>' unless name
|
|
46
|
+
|
|
47
|
+
client = Base.build_client(opts)
|
|
48
|
+
client.post("core/firmware/install/#{name}", {})
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Uninstalls a plugin.
|
|
52
|
+
# Usage: opn-api uninstall <plugin_name>
|
|
53
|
+
#
|
|
54
|
+
# @param args [Array<String>] [plugin_name]
|
|
55
|
+
# @param opts [Hash] Global CLI options
|
|
56
|
+
# @return [Hash] API response
|
|
57
|
+
def uninstall(args, opts)
|
|
58
|
+
name = args.shift
|
|
59
|
+
raise OpnApi::Error, 'Usage: opn-api uninstall <plugin_name>' unless name
|
|
60
|
+
|
|
61
|
+
client = Base.build_client(opts)
|
|
62
|
+
client.post("core/firmware/remove/#{name}", {})
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpnApi
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# CLI commands for service reconfigure operations.
|
|
7
|
+
module Reconfigure
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Lists all registered reconfigure groups.
|
|
11
|
+
#
|
|
12
|
+
# @param _args [Array<String>] Unused
|
|
13
|
+
# @param _opts [Hash] Unused
|
|
14
|
+
# @return [Array<Hash>] Group information
|
|
15
|
+
def groups(_args, _opts)
|
|
16
|
+
OpnApi::ServiceReconfigure.registered_names.sort.map do |name|
|
|
17
|
+
OpnApi::ServiceReconfigure[name]
|
|
18
|
+
{ 'group' => name.to_s }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Triggers reconfigure for a specific group on a device.
|
|
23
|
+
# Usage: opn-api reconfigure <group>
|
|
24
|
+
#
|
|
25
|
+
# @param args [Array<String>] [group_name]
|
|
26
|
+
# @param opts [Hash] Global CLI options
|
|
27
|
+
# @return [Hash] Reconfigure results
|
|
28
|
+
def run(args, opts)
|
|
29
|
+
group_name = args.shift
|
|
30
|
+
raise OpnApi::Error, 'Usage: opn-api reconfigure <group>' unless group_name
|
|
31
|
+
|
|
32
|
+
client = Base.build_client(opts)
|
|
33
|
+
group = OpnApi::ServiceReconfigure[group_name.to_sym]
|
|
34
|
+
group.mark(opts[:device], client)
|
|
35
|
+
group.run
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpnApi
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# CLI commands for structured resource CRUD operations.
|
|
7
|
+
#
|
|
8
|
+
# Resources can be specified in two ways:
|
|
9
|
+
# 1. Registry name (preferred): opn-api search haproxy_server
|
|
10
|
+
# 2. Raw module/controller/type: opn-api search haproxy settings search_servers
|
|
11
|
+
#
|
|
12
|
+
# The registry handles the inconsistent OPNsense endpoint naming
|
|
13
|
+
# (plural search, camelCase, bare names) transparently.
|
|
14
|
+
#
|
|
15
|
+
# Singleton resources (settings) are auto-detected from the registry
|
|
16
|
+
# and work without UUID for show/update.
|
|
17
|
+
module Resource
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Searches for resources.
|
|
21
|
+
# Usage: opn-api search <resource_name>
|
|
22
|
+
# opn-api search <module> <controller> <type>
|
|
23
|
+
#
|
|
24
|
+
# @param args [Array<String>] Resource name or [module, controller, type]
|
|
25
|
+
# @param opts [Hash] Global CLI options
|
|
26
|
+
# @return [Object] Search results
|
|
27
|
+
def search(args, opts)
|
|
28
|
+
resource_name = args.first
|
|
29
|
+
res = build_resource(args, opts, 'search')
|
|
30
|
+
result = res.search
|
|
31
|
+
return result unless res.singleton
|
|
32
|
+
|
|
33
|
+
# Singleton: unwrap, filter, normalize (same as show)
|
|
34
|
+
result = result.values.first if result.is_a?(Hash) && result.length == 1 && result.values.first.is_a?(Hash)
|
|
35
|
+
result = filter_singleton_response(resource_name, result) if result.is_a?(Hash)
|
|
36
|
+
OpnApi::Normalize.normalize_config(result)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Shows a single resource by UUID, or a singleton settings resource.
|
|
40
|
+
# Usage: opn-api show <resource_name> <uuid>
|
|
41
|
+
# opn-api show <singleton_resource>
|
|
42
|
+
#
|
|
43
|
+
# @param args [Array<String>] Resource name + uuid, or singleton name
|
|
44
|
+
# @param opts [Hash] Global CLI options
|
|
45
|
+
# @return [Hash] Resource data
|
|
46
|
+
def show(args, opts)
|
|
47
|
+
# Capture resource name before it's shifted from args
|
|
48
|
+
resource_name = args.first
|
|
49
|
+
res, remaining = build_resource_with_remainder(args, opts, 'show')
|
|
50
|
+
|
|
51
|
+
result = if res.singleton
|
|
52
|
+
res.show_settings
|
|
53
|
+
else
|
|
54
|
+
uuid = remaining.shift
|
|
55
|
+
raise OpnApi::Error, "Usage: opn-api show <resource> <uuid>\n#{registry_hint}" unless uuid
|
|
56
|
+
|
|
57
|
+
res.get(uuid)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Unwrap single-key response (e.g. {"alias": {...}} → inner hash)
|
|
61
|
+
result = result.values.first if result.is_a?(Hash) && result.length == 1 && result.values.first.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
# Filter singleton response to settings-only keys (matching puppet-opn behavior)
|
|
64
|
+
result = filter_singleton_response(resource_name, result) if res.singleton && result.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
# Normalize selection hashes for human-readable output
|
|
67
|
+
OpnApi::Normalize.normalize_config(result)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Creates a new resource.
|
|
71
|
+
# Usage: opn-api create <resource_name> [-j json | stdin]
|
|
72
|
+
#
|
|
73
|
+
# @param args [Array<String>] Resource name, then JSON
|
|
74
|
+
# @param opts [Hash] Global CLI options
|
|
75
|
+
# @return [Hash] API response
|
|
76
|
+
def create(args, opts)
|
|
77
|
+
res, remaining = build_resource_with_remainder(args, opts, 'create')
|
|
78
|
+
raise OpnApi::Error, 'Singleton resources do not support create. Use update instead.' if res.singleton
|
|
79
|
+
|
|
80
|
+
data = Base.read_json_input(remaining)
|
|
81
|
+
result = res.add(data)
|
|
82
|
+
check_result(result, 'create', args)
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Updates an existing resource by UUID, or a singleton settings resource.
|
|
87
|
+
# Usage: opn-api update <resource_name> <uuid> [-j json | stdin]
|
|
88
|
+
# opn-api update <singleton_resource> [-j json | stdin]
|
|
89
|
+
#
|
|
90
|
+
# @param args [Array<String>] Resource + uuid, then JSON
|
|
91
|
+
# @param opts [Hash] Global CLI options
|
|
92
|
+
# @return [Hash] API response
|
|
93
|
+
def update(args, opts)
|
|
94
|
+
res, remaining = build_resource_with_remainder(args, opts, 'update')
|
|
95
|
+
|
|
96
|
+
if res.singleton
|
|
97
|
+
data = Base.read_json_input(remaining)
|
|
98
|
+
result = res.update_settings(data)
|
|
99
|
+
else
|
|
100
|
+
uuid = remaining.shift
|
|
101
|
+
raise OpnApi::Error, "Usage: opn-api update <resource> <uuid> -j '<json>'" unless uuid
|
|
102
|
+
|
|
103
|
+
data = Base.read_json_input(remaining)
|
|
104
|
+
result = res.set(uuid, data)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
check_result(result, 'update', args)
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Deletes a resource by UUID.
|
|
112
|
+
# Usage: opn-api delete <resource_name> <uuid>
|
|
113
|
+
#
|
|
114
|
+
# @param args [Array<String>] Resource + uuid
|
|
115
|
+
# @param opts [Hash] Global CLI options
|
|
116
|
+
# @return [Hash] API response
|
|
117
|
+
def delete(args, opts)
|
|
118
|
+
res, remaining = build_resource_with_remainder(args, opts, 'delete')
|
|
119
|
+
raise OpnApi::Error, 'Singleton resources do not support delete.' if res.singleton
|
|
120
|
+
|
|
121
|
+
uuid = remaining.shift
|
|
122
|
+
raise OpnApi::Error, 'Usage: opn-api delete <resource> <uuid>' unless uuid
|
|
123
|
+
|
|
124
|
+
result = res.del(uuid)
|
|
125
|
+
check_result(result, 'delete', args)
|
|
126
|
+
result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Lists all known resource types from the registry.
|
|
130
|
+
# Usage: opn-api resources
|
|
131
|
+
#
|
|
132
|
+
# @param _args [Array<String>] Unused
|
|
133
|
+
# @param _opts [Hash] Unused
|
|
134
|
+
# @return [Array<Hash>] Resource type information
|
|
135
|
+
def list(_args, _opts)
|
|
136
|
+
OpnApi::ResourceRegistry.names.map do |name|
|
|
137
|
+
entry = OpnApi::ResourceRegistry.lookup(name)
|
|
138
|
+
type = entry[:singleton] ? 'singleton' : 'crud'
|
|
139
|
+
{ 'resource' => name, 'wrapper_key' => entry[:wrapper].to_s, 'type' => type }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Checks the API result for failure and raises with the full API response.
|
|
144
|
+
def check_result(result, action, args)
|
|
145
|
+
return unless result.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
status = (result['result'] || result['status']).to_s.strip.downcase
|
|
148
|
+
return unless status == 'failed'
|
|
149
|
+
|
|
150
|
+
msg = "#{action} failed: #{JSON.generate(result)}"
|
|
151
|
+
|
|
152
|
+
# When OPNsense returns only {"result":"failed"} without validation
|
|
153
|
+
# details, the most common cause is a wrong wrapper key in the JSON body.
|
|
154
|
+
if result.keys == ['result']
|
|
155
|
+
wrapper_hint = detect_wrapper_key(args)
|
|
156
|
+
msg += "\nHint: The JSON wrapper key is likely wrong. "
|
|
157
|
+
msg += "Expected wrapper key: '#{wrapper_hint}'. " if wrapper_hint
|
|
158
|
+
msg += 'The endpoint type is NOT the wrapper key. ' unless wrapper_hint
|
|
159
|
+
msg += 'Use "opn-api show -f json" on an existing resource to find the correct wrapper key. '
|
|
160
|
+
msg += 'Use -v for full request/response details.'
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
raise OpnApi::Error, msg
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Tries to detect the correct wrapper key from args.
|
|
167
|
+
def detect_wrapper_key(args)
|
|
168
|
+
name = args.is_a?(Array) ? args.first : nil
|
|
169
|
+
return nil unless name
|
|
170
|
+
|
|
171
|
+
entry = OpnApi::ResourceRegistry.lookup(name)
|
|
172
|
+
entry&.dig(:wrapper)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Builds a Resource from args.
|
|
176
|
+
def build_resource(args, opts, command)
|
|
177
|
+
build_resource_with_remainder(args, opts, command).first
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Builds a Resource and returns [resource, remaining_args].
|
|
181
|
+
# Tries registry lookup first, then raw mode.
|
|
182
|
+
def build_resource_with_remainder(args, opts, command)
|
|
183
|
+
raise OpnApi::Error, "Usage: opn-api #{command} <resource>\n#{registry_hint}" if args.empty?
|
|
184
|
+
|
|
185
|
+
client = Base.build_client(opts)
|
|
186
|
+
|
|
187
|
+
# Try registry lookup (single arg)
|
|
188
|
+
entry = OpnApi::ResourceRegistry.lookup(args.first)
|
|
189
|
+
if entry
|
|
190
|
+
name = args.shift
|
|
191
|
+
res = OpnApi::ResourceRegistry.build(client, name)
|
|
192
|
+
return [res, args]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Fallback: raw module/controller/type (three args)
|
|
196
|
+
if args.length >= 3 && !args[0].start_with?('-')
|
|
197
|
+
mod = args.shift
|
|
198
|
+
ctrl = args.shift
|
|
199
|
+
type = args.shift
|
|
200
|
+
res = OpnApi::Resource.new(
|
|
201
|
+
client: client,
|
|
202
|
+
module_name: mod,
|
|
203
|
+
controller: ctrl,
|
|
204
|
+
resource_type: type,
|
|
205
|
+
)
|
|
206
|
+
return [res, args]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
raise OpnApi::Error,
|
|
210
|
+
"Unknown resource: '#{args.first}'. " \
|
|
211
|
+
"Run 'opn-api resources' for known types, or use three args: <module> <controller> <type>"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Filters a singleton response to only include settings keys,
|
|
215
|
+
# excluding sub-resource data. Matches puppet-opn behavior:
|
|
216
|
+
# - response_dig with 1 element → dig into that sub-key
|
|
217
|
+
# - response_dig with 2+ elements → slice those keys
|
|
218
|
+
# - response_reject → reject those keys
|
|
219
|
+
# - neither → return unfiltered
|
|
220
|
+
def filter_singleton_response(resource_name, result)
|
|
221
|
+
entry = OpnApi::ResourceRegistry.lookup(resource_name)
|
|
222
|
+
return result unless entry
|
|
223
|
+
|
|
224
|
+
if entry[:response_dig]
|
|
225
|
+
keys = entry[:response_dig]
|
|
226
|
+
if keys.length == 1
|
|
227
|
+
# Single key: dig into sub-key (e.g. acmeclient → settings)
|
|
228
|
+
result[keys.first] || result
|
|
229
|
+
else
|
|
230
|
+
# Multiple keys: slice those settings sections
|
|
231
|
+
result.slice(*keys)
|
|
232
|
+
end
|
|
233
|
+
elsif entry[:response_reject]
|
|
234
|
+
# Reject sub-resource keys (e.g. zabbix_agent → reject aliases, userparameters)
|
|
235
|
+
result.except(*entry[:response_reject])
|
|
236
|
+
else
|
|
237
|
+
result
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Hint text for error messages.
|
|
242
|
+
def registry_hint
|
|
243
|
+
"Run 'opn-api resources' for a list of known resource types."
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|