radfish 0.2.12 → 0.3.1
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 +4 -4
- data/README.md +113 -9
- data/lib/radfish/boot_info.rb +22 -0
- data/lib/radfish/cli.rb +20 -9
- data/lib/radfish/core/base_client.rb +1 -1
- data/lib/radfish/core/boot.rb +2 -2
- data/lib/radfish/core/session.rb +20 -20
- data/lib/radfish/http_client.rb +102 -11
- data/lib/radfish/vendor_detector.rb +26 -17
- data/lib/radfish/version.rb +1 -1
- data/lib/radfish.rb +4 -2
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28b19abfcfc47b88d31ad43a17e67b9f0de71ac358c44bdb8e7e6f91b80a775f
|
|
4
|
+
data.tar.gz: d6e34c43743f79bfbfbb874545deedf1a487bfdfd6c7111683baebad257b5f45
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c62952bdd04c892f39f6fe44785bfd8f7b12800f27eaac6ef7ba2555380027401acfb2ed76fe5a0d4628917ababcde8fd4dba12dd61bc0ecfa88ea97c9d9b0c
|
|
7
|
+
data.tar.gz: 51a2cd9988e9b3e39e4af3c382308f48128aa52d3f5ab52857e4b11425e775f996a559fc1d77a1353d321e832c8e5266336bf597f55b897666be4e6c8981a962
|
data/README.md
CHANGED
|
@@ -35,6 +35,76 @@ Future adapters:
|
|
|
35
35
|
- radfish-asrockrack
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
### The HTTP layer
|
|
39
|
+
|
|
40
|
+
`Radfish::HttpClient` is the single seam between the library and the network.
|
|
41
|
+
Faraday is an implementation detail behind it: the vendor detector, the core
|
|
42
|
+
classes and the adapters talk to `HttpClient` (or to `BaseClient#http_get` and
|
|
43
|
+
friends, which delegate to it) and never build a Faraday connection of their
|
|
44
|
+
own. Everything that BMCs make awkward lives in that one class - permissive TLS
|
|
45
|
+
for old firmware, the `Host` header for SSH tunnels, retries, redirects, debug
|
|
46
|
+
logging, and the translation of transport failures into Radfish errors.
|
|
47
|
+
|
|
48
|
+
Two consequences worth knowing:
|
|
49
|
+
|
|
50
|
+
- **Callers rescue Radfish errors, not Faraday errors.** `HttpClient` converts
|
|
51
|
+
Faraday's connection, timeout and SSL failures into `Radfish::ConnectionError`
|
|
52
|
+
and `Radfish::TimeoutError`. Code above the seam that rescues `Faraday::Error`
|
|
53
|
+
catches nothing.
|
|
54
|
+
- **Adapters that wrap another gem are outside the seam.** The Dell and
|
|
55
|
+
Supermicro adapters delegate to the `idrac` and `supermicro` gems, which have
|
|
56
|
+
their own HTTP stacks, so the behaviour described here does not apply to their
|
|
57
|
+
requests.
|
|
58
|
+
|
|
59
|
+
#### Errors
|
|
60
|
+
|
|
61
|
+
All library errors descend from `Radfish::Error`, so one rescue catches
|
|
62
|
+
everything the library raises:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
Radfish::Error
|
|
66
|
+
├── Radfish::ConnectionError # unreachable host, refused connection, TLS failure
|
|
67
|
+
├── Radfish::TimeoutError # request took too long
|
|
68
|
+
│ └── Radfish::BootProgressTimeout
|
|
69
|
+
├── Radfish::AuthenticationError # bad credentials
|
|
70
|
+
├── Radfish::NotFoundError
|
|
71
|
+
├── Radfish::UnsupportedVendorError # no adapter for this BMC
|
|
72
|
+
├── Radfish::VirtualMediaError # + NotFound / Connection / License / Busy
|
|
73
|
+
└── Radfish::TaskError # + TaskTimeoutError / TaskFailedError
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
#### Retries
|
|
77
|
+
|
|
78
|
+
Failed requests are retried with an exponential backoff on 408, 429, 500, 502,
|
|
79
|
+
503 and 504, and on connection and timeout failures. **Only idempotent methods
|
|
80
|
+
are retried** - GET, HEAD, PUT and DELETE. A repeated POST is not safe on
|
|
81
|
+
Redfish: it can mean a second session, a second reset, or a second job. A caller
|
|
82
|
+
that knows its POST is safe to repeat can widen the set for one client:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
Radfish::HttpClient.new(
|
|
86
|
+
host: '192.168.1.100',
|
|
87
|
+
retry_count: 3, # attempts after the first
|
|
88
|
+
retry_delay: 1, # initial delay, doubled each retry
|
|
89
|
+
retry_methods: Radfish::HttpClient::IDEMPOTENT_METHODS + [:post]
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For a whole operation rather than a single request, `BaseClient#with_retries`
|
|
94
|
+
wraps a block.
|
|
95
|
+
|
|
96
|
+
#### Redirects
|
|
97
|
+
|
|
98
|
+
Some BMCs (AMI MegaRAC, for one) redirect `/redfish/v1` to `/redfish/v1/`.
|
|
99
|
+
`HttpClient` follows redirects on GET and HEAD, up to `max_redirects` hops
|
|
100
|
+
(3 by default; pass `max_redirects: 0` on a call to get the 3xx response
|
|
101
|
+
itself). A redirect is only followed back to the same endpoint - a relative
|
|
102
|
+
path, or an absolute URL whose scheme, host and port match the BMC already
|
|
103
|
+
being addressed - because every request carries credentials and Faraday would
|
|
104
|
+
otherwise hand them to whatever host the `Location` header named. POST and
|
|
105
|
+
PATCH redirects are returned to the caller rather than followed, since
|
|
106
|
+
301/302/303 do not preserve the method.
|
|
107
|
+
|
|
38
108
|
## Features
|
|
39
109
|
|
|
40
110
|
### Automatic Vendor Detection
|
|
@@ -56,6 +126,20 @@ Regardless of vendor, all adapters provide:
|
|
|
56
126
|
### Vendor-Specific Features
|
|
57
127
|
Adapters can expose vendor-specific functionality while maintaining the common interface.
|
|
58
128
|
|
|
129
|
+
## Compatibility
|
|
130
|
+
|
|
131
|
+
`activesupport` below 8.1 and `json` 3 cannot be used together: activesupport's
|
|
132
|
+
JSON encoder calls `JSON.generate(..., quirks_mode: true)`, and json 3 removed
|
|
133
|
+
that keyword, so any `hash.to_json` raises `ArgumentError: unknown keyword:
|
|
134
|
+
quirks_mode`. This is not specific to this gem — it bites anything that loads
|
|
135
|
+
`active_support/core_ext` — but radfish depends on activesupport, so pick one
|
|
136
|
+
of:
|
|
137
|
+
|
|
138
|
+
- activesupport >= 8.1 with json 3, or
|
|
139
|
+
- activesupport 7.x with json 2.
|
|
140
|
+
|
|
141
|
+
CI covers both ends.
|
|
142
|
+
|
|
59
143
|
## Installation
|
|
60
144
|
|
|
61
145
|
Add to your Gemfile:
|
|
@@ -183,13 +267,28 @@ All commands support these options:
|
|
|
183
267
|
--host, -h HOST # BMC hostname or IP address
|
|
184
268
|
--username, -u USER # BMC username
|
|
185
269
|
--password, -p PASS # BMC password
|
|
186
|
-
--vendor VENDOR
|
|
270
|
+
--vendor, -v VENDOR # Force specific vendor (dell, supermicro, etc.)
|
|
187
271
|
--port PORT # BMC port (default: 443)
|
|
272
|
+
--config, -c FILE # Read options from a YAML config file
|
|
188
273
|
--json # Output in JSON format
|
|
189
|
-
--verbose
|
|
190
|
-
--
|
|
274
|
+
--verbose # Application-level progress messages
|
|
275
|
+
--debug [N] # HTTP-level debug output (default 2, see below)
|
|
276
|
+
--insecure # Skip SSL certificate verification (default: true)
|
|
191
277
|
```
|
|
192
278
|
|
|
279
|
+
`--verbose` and `--debug` set the same verbosity level, and `--debug` wins:
|
|
280
|
+
|
|
281
|
+
| Level | Flag | Output |
|
|
282
|
+
|-------|---------------|-----------------------------------------------------|
|
|
283
|
+
| 0 | (none) | Results only |
|
|
284
|
+
| 1 | `--verbose` | Progress messages from the library |
|
|
285
|
+
| 2 | `--debug` | Adds the HTTP request and response log |
|
|
286
|
+
| 3 | `--debug 3` | Adds request and response bodies, and call sites |
|
|
287
|
+
|
|
288
|
+
The `Authorization` header and password fields are filtered out of the log, but
|
|
289
|
+
level 3 prints request and response bodies, which can carry other sensitive
|
|
290
|
+
data - read it before pasting it into an issue.
|
|
291
|
+
|
|
193
292
|
### Output Formats
|
|
194
293
|
|
|
195
294
|
#### Default (Human-Readable)
|
|
@@ -391,7 +490,7 @@ client.mount_iso_and_boot("http://example.com/os.iso")
|
|
|
391
490
|
options = client.boot_options
|
|
392
491
|
|
|
393
492
|
# Set one-time boot
|
|
394
|
-
client.set_boot_override("Pxe",
|
|
493
|
+
client.set_boot_override("Pxe", persistence: 'Once')
|
|
395
494
|
|
|
396
495
|
# Quick boot methods
|
|
397
496
|
client.boot_to_pxe
|
|
@@ -457,8 +556,10 @@ Radfish::Client.new(
|
|
|
457
556
|
use_ssl: true, # Use HTTPS
|
|
458
557
|
verify_ssl: false, # Verify certificates
|
|
459
558
|
direct_mode: false, # Use Basic Auth instead of sessions
|
|
460
|
-
retry_count: 3, #
|
|
461
|
-
retry_delay: 1, # Initial delay between retries
|
|
559
|
+
retry_count: 3, # Retries after the first attempt
|
|
560
|
+
retry_delay: 1, # Initial delay between retries, doubled each time
|
|
561
|
+
retry_methods: nil, # Defaults to idempotent methods only
|
|
562
|
+
max_redirects: 3, # Same-endpoint redirects to follow (0 disables)
|
|
462
563
|
verbosity: 0 # Debug output level (0-3)
|
|
463
564
|
)
|
|
464
565
|
```
|
|
@@ -468,11 +569,14 @@ Radfish::Client.new(
|
|
|
468
569
|
Enable verbose output:
|
|
469
570
|
|
|
470
571
|
```ruby
|
|
471
|
-
client.verbosity = 1 #
|
|
472
|
-
client.verbosity = 2 #
|
|
473
|
-
client.verbosity = 3 #
|
|
572
|
+
client.verbosity = 1 # Progress messages from the library
|
|
573
|
+
client.verbosity = 2 # Adds the HTTP request and response log
|
|
574
|
+
client.verbosity = 3 # Adds request and response bodies, and call sites
|
|
474
575
|
```
|
|
475
576
|
|
|
577
|
+
These are the same levels the CLI sets with `--verbose` and `--debug N`. The
|
|
578
|
+
`Authorization` header and password fields are filtered out of the log.
|
|
579
|
+
|
|
476
580
|
## Supported Vendors
|
|
477
581
|
|
|
478
582
|
Currently supported:
|
data/lib/radfish/boot_info.rb
CHANGED
|
@@ -29,6 +29,28 @@ module Radfish
|
|
|
29
29
|
match ? adapter.disable_boot_entries(match: match, **opts) : adapter.disable_boot_entries(**opts)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# One-time boot to the virtual CD via the vendor's reliable path (Dell: an SCP import that drains
|
|
33
|
+
# any pending config job first, then sets ServerBoot BootOnce + FirstBootDevice=VCD-DVD). Returns
|
|
34
|
+
# the vendor result; raises NotImplementedError on an adapter with no SCP one-time-boot path.
|
|
35
|
+
def set_one_time_cd_boot(**opts)
|
|
36
|
+
require_adapter!(:set_one_time_cd_boot)
|
|
37
|
+
adapter.set_one_time_cd_boot(**opts)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Poll the config job +jid+ (e.g. the BIOS config job a boot-source change schedules) to a
|
|
41
|
+
# terminal state. Returns the state string, or nil on timeout (never raises).
|
|
42
|
+
def wait_config_job(jid, **opts)
|
|
43
|
+
require_adapter!(:wait_config_job)
|
|
44
|
+
adapter.wait_config_job(jid, **opts)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Drain every pending (non-Completed) config job so scheduling a new one does not trip LC068.
|
|
48
|
+
# Returns the ids drained.
|
|
49
|
+
def drain_config_jobs
|
|
50
|
+
require_adapter!(:drain_pending_config_jobs!)
|
|
51
|
+
adapter.drain_pending_config_jobs!
|
|
52
|
+
end
|
|
53
|
+
|
|
32
54
|
# Underlying boot configuration hash (BootSourceOverride*, boot order, ...).
|
|
33
55
|
def to_h
|
|
34
56
|
adapter.boot_config
|
data/lib/radfish/cli.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Radfish
|
|
|
16
16
|
class_option :port, type: :numeric, default: 443, desc: 'BMC port (env: RADFISH_PORT)'
|
|
17
17
|
class_option :insecure, type: :boolean, default: true, desc: 'Skip SSL verification'
|
|
18
18
|
class_option :verbose, type: :boolean, default: false, desc: 'Enable verbose output'
|
|
19
|
+
class_option :debug, type: :numeric, lazy_default: 2, desc: 'Debug output level'
|
|
19
20
|
class_option :json, type: :boolean, default: false, desc: 'Output in JSON format'
|
|
20
21
|
|
|
21
22
|
desc "detect", "Detect the vendor of a BMC"
|
|
@@ -25,6 +26,7 @@ module Radfish
|
|
|
25
26
|
host: opts[:host],
|
|
26
27
|
username: opts[:username],
|
|
27
28
|
password: opts[:password],
|
|
29
|
+
verbosity: opts[:verbosity],
|
|
28
30
|
port: opts[:port],
|
|
29
31
|
verify_ssl: !opts[:insecure]
|
|
30
32
|
)
|
|
@@ -336,7 +338,7 @@ module Radfish
|
|
|
336
338
|
# Storage Commands
|
|
337
339
|
desc "storage SUBCOMMAND", "Storage information"
|
|
338
340
|
def storage(subcommand = 'summary')
|
|
339
|
-
with_client do |client|
|
|
341
|
+
with_client do |client, opts|
|
|
340
342
|
case subcommand
|
|
341
343
|
when 'summary', 'all'
|
|
342
344
|
data = client.storage_summary
|
|
@@ -370,7 +372,7 @@ module Radfish
|
|
|
370
372
|
all_drives.concat(drives) if drives
|
|
371
373
|
rescue => e
|
|
372
374
|
ctrl_name = controller.respond_to?(:name) ? controller.name : 'controller'
|
|
373
|
-
puts "Error fetching drives for #{ctrl_name}: #{e.message}".yellow if
|
|
375
|
+
puts "Error fetching drives for #{ctrl_name}: #{e.message}".yellow if opts[:verbosity] > 0
|
|
374
376
|
end
|
|
375
377
|
end
|
|
376
378
|
|
|
@@ -402,7 +404,7 @@ module Radfish
|
|
|
402
404
|
all_volumes.concat(volumes) if volumes
|
|
403
405
|
rescue => e
|
|
404
406
|
ctrl_name = controller.respond_to?(:name) ? controller.name : 'controller'
|
|
405
|
-
puts "Error fetching volumes for #{ctrl_name}: #{e.message}".yellow if
|
|
407
|
+
puts "Error fetching volumes for #{ctrl_name}: #{e.message}".yellow if opts[:verbosity] > 0
|
|
406
408
|
end
|
|
407
409
|
end
|
|
408
410
|
|
|
@@ -652,17 +654,17 @@ module Radfish
|
|
|
652
654
|
password: opts[:password],
|
|
653
655
|
port: opts[:port],
|
|
654
656
|
verify_ssl: !opts[:insecure],
|
|
655
|
-
direct_mode: true
|
|
657
|
+
direct_mode: true,
|
|
658
|
+
verbosity: opts[:verbosity]
|
|
656
659
|
}
|
|
657
660
|
|
|
658
661
|
client_opts[:vendor] = opts[:vendor] if opts[:vendor]
|
|
659
662
|
|
|
660
663
|
begin
|
|
661
664
|
client = Radfish::Client.new(**client_opts)
|
|
662
|
-
|
|
663
|
-
|
|
665
|
+
|
|
664
666
|
client.login
|
|
665
|
-
yield client
|
|
667
|
+
yield client, opts
|
|
666
668
|
rescue => e
|
|
667
669
|
error "Error: #{e.message}"
|
|
668
670
|
exit 1
|
|
@@ -695,7 +697,16 @@ module Radfish
|
|
|
695
697
|
opts[:port] = options[:port] if options[:port]
|
|
696
698
|
opts[:insecure] = options[:insecure] if options.key?(:insecure)
|
|
697
699
|
opts[:verbose] = options[:verbose] if options.key?(:verbose)
|
|
698
|
-
|
|
700
|
+
opts[:debug] = options[:debug] if options.key?(:debug)
|
|
701
|
+
|
|
702
|
+
if opts[:debug]
|
|
703
|
+
opts[:verbosity] = opts[:debug].to_i
|
|
704
|
+
elsif opts[:verbose]
|
|
705
|
+
opts[:verbosity] = 1
|
|
706
|
+
else
|
|
707
|
+
opts[:verbosity] = 0
|
|
708
|
+
end
|
|
709
|
+
|
|
699
710
|
# Check environment variables as fallback
|
|
700
711
|
opts[:host] ||= ENV['RADFISH_HOST']
|
|
701
712
|
opts[:username] ||= ENV['RADFISH_USERNAME']
|
|
@@ -713,7 +724,7 @@ module Radfish
|
|
|
713
724
|
def safe_call
|
|
714
725
|
yield
|
|
715
726
|
rescue => e
|
|
716
|
-
options[:verbose] ? e.message : 'N/A'
|
|
727
|
+
(options[:verbose] || options[:debug].to_i > 0) ? e.message : 'N/A'
|
|
717
728
|
end
|
|
718
729
|
|
|
719
730
|
def error(message)
|
data/lib/radfish/core/boot.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Radfish
|
|
|
7
7
|
raise NotImplementedError, "Adapter must implement #boot_config"
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def set_boot_override(target,
|
|
10
|
+
def set_boot_override(target, persistence: nil, mode: nil)
|
|
11
11
|
raise NotImplementedError, "Adapter must implement #set_boot_override"
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -53,4 +53,4 @@ module Radfish
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
|
-
end
|
|
56
|
+
end
|
data/lib/radfish/core/session.rb
CHANGED
|
@@ -14,9 +14,14 @@ module Radfish
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def connection
|
|
17
|
-
@connection ||=
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
@connection ||= begin
|
|
18
|
+
url = URI(client.base_url)
|
|
19
|
+
HttpClient.new(host: url.host, port: url.port,
|
|
20
|
+
use_ssl: url.scheme == 'https', verify_ssl: client.verify_ssl,
|
|
21
|
+
host_header: client.host_header,
|
|
22
|
+
retry_count: client.retry_count,
|
|
23
|
+
retry_delay: client.retry_delay,
|
|
24
|
+
verbosity: verbosity)
|
|
20
25
|
end
|
|
21
26
|
end
|
|
22
27
|
|
|
@@ -32,10 +37,9 @@ module Radfish
|
|
|
32
37
|
'Content-Type' => 'application/json',
|
|
33
38
|
'Accept' => 'application/json'
|
|
34
39
|
}
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
|
|
37
41
|
begin
|
|
38
|
-
response = connection.post('/redfish/v1/SessionService/Sessions', payload, headers)
|
|
42
|
+
response = connection.post('/redfish/v1/SessionService/Sessions', body: payload, headers: headers)
|
|
39
43
|
|
|
40
44
|
if response.status == 201
|
|
41
45
|
@x_auth_token = response.headers['x-auth-token']
|
|
@@ -50,14 +54,14 @@ module Radfish
|
|
|
50
54
|
rescue JSON::ParserError
|
|
51
55
|
end
|
|
52
56
|
|
|
53
|
-
debug "Session created successfully
|
|
57
|
+
debug "Session #{@session_id || '?'} created successfully (token #{@x_auth_token ? 'received' : 'missing'})", 1, :green
|
|
54
58
|
return true
|
|
55
59
|
else
|
|
56
60
|
debug "Failed to create session. Status: #{response.status}", 1, :red
|
|
57
|
-
debug "Response: #{response.body}", 2
|
|
61
|
+
debug "Response: #{HttpClient.scrub(response.body)}", 2
|
|
58
62
|
return false
|
|
59
63
|
end
|
|
60
|
-
rescue
|
|
64
|
+
rescue Radfish::Error => e
|
|
61
65
|
debug "Connection error creating session: #{e.message}", 1, :red
|
|
62
66
|
return false
|
|
63
67
|
end
|
|
@@ -72,10 +76,9 @@ module Radfish
|
|
|
72
76
|
'X-Auth-Token' => @x_auth_token,
|
|
73
77
|
'Accept' => 'application/json'
|
|
74
78
|
}
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
|
|
77
80
|
begin
|
|
78
|
-
response = connection.delete("/redfish/v1/SessionService/Sessions/#{@session_id}",
|
|
81
|
+
response = connection.delete("/redfish/v1/SessionService/Sessions/#{@session_id}", headers: headers)
|
|
79
82
|
|
|
80
83
|
if response.status == 204 || response.status == 200
|
|
81
84
|
debug "Session deleted successfully", 1, :green
|
|
@@ -86,7 +89,7 @@ module Radfish
|
|
|
86
89
|
debug "Failed to delete session. Status: #{response.status}", 1, :yellow
|
|
87
90
|
return false
|
|
88
91
|
end
|
|
89
|
-
rescue
|
|
92
|
+
rescue Radfish::Error => e
|
|
90
93
|
debug "Error deleting session: #{e.message}", 1, :yellow
|
|
91
94
|
return false
|
|
92
95
|
end
|
|
@@ -99,21 +102,18 @@ module Radfish
|
|
|
99
102
|
'X-Auth-Token' => @x_auth_token,
|
|
100
103
|
'Accept' => 'application/json'
|
|
101
104
|
}
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
|
|
104
106
|
begin
|
|
105
|
-
response = connection.get("/redfish/v1/SessionService/Sessions/#{@session_id}",
|
|
107
|
+
response = connection.get("/redfish/v1/SessionService/Sessions/#{@session_id}", headers: headers)
|
|
106
108
|
response.status == 200
|
|
107
109
|
rescue
|
|
108
110
|
false
|
|
109
111
|
end
|
|
110
112
|
end
|
|
111
|
-
|
|
112
|
-
private
|
|
113
|
-
|
|
113
|
+
|
|
114
114
|
def verbosity
|
|
115
115
|
client.verbosity
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
end
|
|
119
|
-
end
|
|
119
|
+
end
|
data/lib/radfish/http_client.rb
CHANGED
|
@@ -11,12 +11,42 @@ module Radfish
|
|
|
11
11
|
class HttpClient
|
|
12
12
|
include Debuggable
|
|
13
13
|
|
|
14
|
+
# Redirects we follow, and the methods we follow them for. GET/HEAD only:
|
|
15
|
+
# 307/308 preserve the method, but 301/302/303 do not, so re-sending a POST
|
|
16
|
+
# body after one would be wrong. Callers doing anything else get the 3xx back.
|
|
17
|
+
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
18
|
+
REDIRECT_METHODS = [:get, :head].freeze
|
|
19
|
+
|
|
20
|
+
# Methods we retry by default. A repeated GET or DELETE is harmless, but a
|
|
21
|
+
# repeated POST is not: on Redfish it can mean a second session, a second
|
|
22
|
+
# reset, a second job. Callers that know a POST is safe to repeat can widen
|
|
23
|
+
# this per client with retry_methods:.
|
|
24
|
+
IDEMPOTENT_METHODS = [:get, :head, :put, :delete].freeze
|
|
25
|
+
RETRY_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
26
|
+
|
|
27
|
+
# Secrets stripped from the debug log. Redfish session payloads use
|
|
28
|
+
# "Password" where other calls use "password", and the session token comes
|
|
29
|
+
# back as a header, so match either case and both header shapes.
|
|
30
|
+
LOG_FILTERS = [
|
|
31
|
+
# Faraday logs a header as `Authorization: "Basic dXNlcjpwdw=="` and a
|
|
32
|
+
# headers hash as `"Authorization"=>"Basic ..."`, so the quote sits
|
|
33
|
+
# between the colon and the scheme. Match both shapes, and any scheme,
|
|
34
|
+
# keeping the scheme itself visible because it is useful and not secret.
|
|
35
|
+
[/(Authorization"?\s*(?:=>|:)\s*"?)((?:Basic|Bearer|Digest)\s+)?[^"\n]+/i, '\1\2[FILTERED]'],
|
|
36
|
+
[/(Password"=>"?)([^,"]+)/i, '\1[FILTERED]'],
|
|
37
|
+
[/("password"\s*:\s*")([^"]+)/i, '\1[FILTERED]'],
|
|
38
|
+
[/((?:X-Auth-Token)"?\s*(?:=>|:)\s*"?)([^",\n]+)/i, '\1[FILTERED]']
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
14
41
|
attr_reader :host, :port, :use_ssl, :verify_ssl
|
|
15
|
-
attr_accessor :username, :password, :verbosity, :retry_count, :retry_delay
|
|
42
|
+
attr_accessor :username, :password, :verbosity, :retry_count, :retry_delay,
|
|
43
|
+
:retry_methods, :max_redirects, :log_device
|
|
16
44
|
|
|
17
45
|
def initialize(host:, port: 443, use_ssl: true, verify_ssl: false,
|
|
18
46
|
username: nil, password: nil, verbosity: 0,
|
|
19
|
-
retry_count: 3, retry_delay: 1,
|
|
47
|
+
retry_count: 3, retry_delay: 1,
|
|
48
|
+
retry_methods: IDEMPOTENT_METHODS, max_redirects: 3,
|
|
49
|
+
log_device: STDOUT, **options)
|
|
20
50
|
@host = host
|
|
21
51
|
@port = port
|
|
22
52
|
@use_ssl = use_ssl
|
|
@@ -26,9 +56,18 @@ module Radfish
|
|
|
26
56
|
@verbosity = verbosity
|
|
27
57
|
@retry_count = retry_count
|
|
28
58
|
@retry_delay = retry_delay
|
|
59
|
+
@retry_methods = retry_methods
|
|
60
|
+
@max_redirects = max_redirects
|
|
61
|
+
@log_device = log_device
|
|
29
62
|
@options = options
|
|
30
63
|
end
|
|
31
64
|
|
|
65
|
+
# The Faraday logger applies LOG_FILTERS, but our own debug lines bypass it,
|
|
66
|
+
# so anything we print that can carry a header or a body goes through here.
|
|
67
|
+
def self.scrub(text)
|
|
68
|
+
LOG_FILTERS.reduce(text.to_s) { |t, (pattern, replacement)| t.gsub(pattern, replacement) }
|
|
69
|
+
end
|
|
70
|
+
|
|
32
71
|
def base_url
|
|
33
72
|
protocol = use_ssl ? 'https' : 'http'
|
|
34
73
|
"#{protocol}://#{host}:#{port}"
|
|
@@ -54,8 +93,12 @@ module Radfish
|
|
|
54
93
|
request(:delete, path, headers: headers, **options)
|
|
55
94
|
end
|
|
56
95
|
|
|
57
|
-
|
|
96
|
+
# max_redirects overrides the client default for one call; pass 0 to get the
|
|
97
|
+
# 3xx response itself rather than what it points at.
|
|
98
|
+
def request(method, path, body: nil, headers: {}, auth: true, timeout: nil,
|
|
99
|
+
max_redirects: nil, **options)
|
|
58
100
|
debug "Starting HTTP #{method.upcase} request to #{path}", 2, :yellow
|
|
101
|
+
redirects_left = max_redirects.nil? ? @max_redirects.to_i : max_redirects.to_i
|
|
59
102
|
|
|
60
103
|
# Add host header if specified (needed for SSH tunnels to iDRAC)
|
|
61
104
|
if @options[:host_header]
|
|
@@ -70,7 +113,7 @@ module Radfish
|
|
|
70
113
|
response = conn.send(method) do |req|
|
|
71
114
|
debug "Setting request URL: #{path}", 3, :cyan
|
|
72
115
|
req.url path
|
|
73
|
-
debug "Merging headers: #{headers}", 3, :cyan
|
|
116
|
+
debug "Merging headers: #{self.class.scrub(headers)}", 3, :cyan
|
|
74
117
|
req.headers.merge!(headers)
|
|
75
118
|
req.body = body if body
|
|
76
119
|
|
|
@@ -90,6 +133,18 @@ module Radfish
|
|
|
90
133
|
|
|
91
134
|
debug "Request completed with status: #{response.status}", 2, :green
|
|
92
135
|
|
|
136
|
+
if redirects_left > 0 && redirect?(method, response)
|
|
137
|
+
target = safe_redirect_path(response['location'])
|
|
138
|
+
|
|
139
|
+
if target.nil?
|
|
140
|
+
debug "Not following redirect to #{response['location'].inspect} - it leaves #{base_url}", 1, :yellow
|
|
141
|
+
else
|
|
142
|
+
debug "Following redirect to #{target}, #{redirects_left - 1} left", 2, :yellow
|
|
143
|
+
return request(method, target, body: body, headers: headers, auth: auth,
|
|
144
|
+
timeout: timeout, max_redirects: redirects_left - 1, **options)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
93
148
|
response
|
|
94
149
|
rescue Faraday::ConnectionFailed => e
|
|
95
150
|
debug "Connection failed: #{e.message}", 1, :red
|
|
@@ -119,6 +174,10 @@ module Radfish
|
|
|
119
174
|
end
|
|
120
175
|
|
|
121
176
|
raise Radfish::ConnectionError, "SSL error connecting to #{host}: #{e.message}"
|
|
177
|
+
rescue Faraday::Error => e
|
|
178
|
+
# HttpClient is the seam: callers speak Radfish errors, not Faraday ones.
|
|
179
|
+
debug "HTTP request failed: #{e.class} - #{e.message}", 1, :red
|
|
180
|
+
raise Radfish::Error, "Request to #{host} failed: #{e.message}"
|
|
122
181
|
rescue => e
|
|
123
182
|
debug "HTTP request failed: #{e.class} - #{e.message}", 1, :red
|
|
124
183
|
debug "Exception backtrace: #{e.backtrace.first(5).join("\n")}", 1, :red
|
|
@@ -127,8 +186,42 @@ module Radfish
|
|
|
127
186
|
raise e
|
|
128
187
|
end
|
|
129
188
|
|
|
189
|
+
def redirect?(method, response)
|
|
190
|
+
REDIRECT_METHODS.include?(method) && REDIRECT_STATUSES.include?(response.status)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The path (with query) to request for +location+, or nil when we must not
|
|
194
|
+
# follow it. Every request we make carries credentials -- Basic auth here, an
|
|
195
|
+
# X-Auth-Token on the adapters' authenticated_request -- and Faraday honours an
|
|
196
|
+
# absolute URL by replacing the host, so a BMC answering with
|
|
197
|
+
# "Location: https://elsewhere/" would be handed those credentials. Accept a
|
|
198
|
+
# relative path, or an absolute URL for this same endpoint, and return only the
|
|
199
|
+
# path so the request stays on this connection.
|
|
200
|
+
#
|
|
201
|
+
# Note: with an SSH tunnel (host_header set) an absolute redirect to the BMC's
|
|
202
|
+
# own name is refused, since that name is not the host we connect to. Relative
|
|
203
|
+
# redirects are unaffected.
|
|
204
|
+
def safe_redirect_path(location)
|
|
205
|
+
location = location.to_s
|
|
206
|
+
return nil if location.empty?
|
|
207
|
+
|
|
208
|
+
uri = URI.parse(location)
|
|
209
|
+
return nil if uri.path.to_s.empty?
|
|
210
|
+
return nil unless uri.host.nil? || same_endpoint?(uri)
|
|
211
|
+
|
|
212
|
+
uri.query ? "#{uri.path}?#{uri.query}" : uri.path
|
|
213
|
+
rescue URI::InvalidURIError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
130
217
|
private
|
|
131
218
|
|
|
219
|
+
def same_endpoint?(uri)
|
|
220
|
+
uri.host == host &&
|
|
221
|
+
uri.port == port &&
|
|
222
|
+
uri.scheme == (use_ssl ? 'https' : 'http')
|
|
223
|
+
end
|
|
224
|
+
|
|
132
225
|
def connection(auth: true)
|
|
133
226
|
@connections ||= {}
|
|
134
227
|
cache_key = auth ? :with_auth : :without_auth
|
|
@@ -182,8 +275,8 @@ module Radfish
|
|
|
182
275
|
Faraday::TimeoutError,
|
|
183
276
|
Faraday::RetriableResponse
|
|
184
277
|
],
|
|
185
|
-
methods:
|
|
186
|
-
retry_statuses:
|
|
278
|
+
methods: retry_methods,
|
|
279
|
+
retry_statuses: RETRY_STATUSES
|
|
187
280
|
# Removed retry_block to debug ArgumentError - can add back later
|
|
188
281
|
}
|
|
189
282
|
|
|
@@ -192,11 +285,9 @@ module Radfish
|
|
|
192
285
|
faraday.options.open_timeout = 10
|
|
193
286
|
|
|
194
287
|
# Add logging if verbose
|
|
195
|
-
if verbosity
|
|
196
|
-
faraday.response :logger, Logger.new(
|
|
197
|
-
logger.filter(
|
|
198
|
-
logger.filter(/(Password"=>"?)([^,"]+)/, '\1[FILTERED]')
|
|
199
|
-
logger.filter(/("password":\s*")([^"]+)/, '\1[FILTERED]')
|
|
288
|
+
if verbosity >= 2
|
|
289
|
+
faraday.response :logger, Logger.new(log_device), { bodies: verbosity >= 3 } do |logger|
|
|
290
|
+
LOG_FILTERS.each { |pattern, replacement| logger.filter(pattern, replacement) }
|
|
200
291
|
end
|
|
201
292
|
end
|
|
202
293
|
|
|
@@ -60,7 +60,29 @@ module Radfish
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
private
|
|
63
|
-
|
|
63
|
+
|
|
64
|
+
def check_response(response)
|
|
65
|
+
if response.status == 200
|
|
66
|
+
debug "Got 200 response, parsing JSON...", 2, :green
|
|
67
|
+
JSON.parse(response.body)
|
|
68
|
+
elsif HttpClient::REDIRECT_STATUSES.include?(response.status)
|
|
69
|
+
# HttpClient follows redirects that stay on this endpoint, so reaching
|
|
70
|
+
# here means it refused one or ran out of them.
|
|
71
|
+
debug "Redirect to #{response['location'].inspect} was not followed (HTTP #{response.status})", 1, :red
|
|
72
|
+
nil
|
|
73
|
+
elsif response.status == 401
|
|
74
|
+
debug "Authentication failed (HTTP 401) - check username/password", 1, :red
|
|
75
|
+
nil
|
|
76
|
+
elsif response.status == 404
|
|
77
|
+
debug "Redfish API not found at /redfish/v1 (HTTP 404)", 1, :red
|
|
78
|
+
nil
|
|
79
|
+
else
|
|
80
|
+
debug "Failed to fetch service root: HTTP #{response.status}", 1, :red
|
|
81
|
+
debug "Response body: #{response.body[0..200]}" if response.body && @verbosity >= 2
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
64
86
|
def fetch_service_root
|
|
65
87
|
begin
|
|
66
88
|
debug "About to make HTTP GET request to /redfish/v1", 2, :yellow
|
|
@@ -69,21 +91,8 @@ module Radfish
|
|
|
69
91
|
debug "Using timeout: #{timeout}s (SSH tunnel detected)" if @host_header
|
|
70
92
|
response = @http_client.get('/redfish/v1', timeout: timeout)
|
|
71
93
|
debug "HTTP GET request completed", 2, :green
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
debug "Got 200 response, parsing JSON...", 2, :green
|
|
75
|
-
JSON.parse(response.body)
|
|
76
|
-
elsif response.status == 401
|
|
77
|
-
debug "Authentication failed (HTTP 401) - check username/password", 1, :red
|
|
78
|
-
nil
|
|
79
|
-
elsif response.status == 404
|
|
80
|
-
debug "Redfish API not found at /redfish/v1 (HTTP 404)", 1, :red
|
|
81
|
-
nil
|
|
82
|
-
else
|
|
83
|
-
debug "Failed to fetch service root: HTTP #{response.status}", 1, :red
|
|
84
|
-
debug "Response body: #{response.body[0..200]}" if response.body && @verbosity >= 2
|
|
85
|
-
nil
|
|
86
|
-
end
|
|
94
|
+
|
|
95
|
+
check_response(response)
|
|
87
96
|
rescue ConnectionError, TimeoutError => e
|
|
88
97
|
debug "Connection failed to #{host}:#{port} - #{e.message}", 1, :red
|
|
89
98
|
nil
|
|
@@ -210,4 +219,4 @@ module Radfish
|
|
|
210
219
|
end
|
|
211
220
|
end
|
|
212
221
|
end
|
|
213
|
-
end
|
|
222
|
+
end
|
data/lib/radfish/version.rb
CHANGED
data/lib/radfish.rb
CHANGED
|
@@ -58,8 +58,10 @@ module Radfish
|
|
|
58
58
|
Client.connect(**options, &block)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def detect_vendor(host:, username:, password:, **options)
|
|
62
|
-
VendorDetector.new(host: host, username: username, password: password, **options)
|
|
61
|
+
def detect_vendor(host:, username:, password:, verbosity: 0, **options)
|
|
62
|
+
vendor_detector = VendorDetector.new(host: host, username: username, password: password, **options)
|
|
63
|
+
vendor_detector.verbosity = verbosity
|
|
64
|
+
vendor_detector.detect
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
def register_adapter(vendor, adapter_class)
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: radfish
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jonathan Siegel
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: exe
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-09-12 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: thor
|
|
@@ -197,6 +198,7 @@ metadata:
|
|
|
197
198
|
homepage_uri: https://github.com/buildio/radfish
|
|
198
199
|
source_code_uri: https://github.com/buildio/radfish
|
|
199
200
|
changelog_uri: https://github.com/buildio/radfish/blob/main/CHANGELOG.md
|
|
201
|
+
post_install_message:
|
|
200
202
|
rdoc_options: []
|
|
201
203
|
require_paths:
|
|
202
204
|
- lib
|
|
@@ -211,7 +213,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
211
213
|
- !ruby/object:Gem::Version
|
|
212
214
|
version: '0'
|
|
213
215
|
requirements: []
|
|
214
|
-
rubygems_version: 3.
|
|
216
|
+
rubygems_version: 3.5.22
|
|
217
|
+
signing_key:
|
|
215
218
|
specification_version: 4
|
|
216
219
|
summary: Unified Redfish API Client for Server Management
|
|
217
220
|
test_files: []
|