radfish 0.2.10 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad19da6f011320835b8e6fc236057a1613a6c01da37672361a06b2b4affe120c
4
- data.tar.gz: e6ef687e6b6c87b653c6bfe52d4781057007032f956618b9f8348eb32874e171
3
+ metadata.gz: ddba2c0632175f4f75abe456074d912b7d6644371ca78699b7979abb4e9dd3de
4
+ data.tar.gz: 44eedf5868fbea6171d2714a922f1ab35dec320293208c5472e32d7d2104ece7
5
5
  SHA512:
6
- metadata.gz: 5f9d5decaf01d69e5098bad75775706f775f81824aa0653fdde5add5561c014eb7169cf6b2eb2c685cb49c1937f196149ca2aa13075ec55ad3765743494b26b7
7
- data.tar.gz: 4e75db9b41a4edf1468bd695eb1014b7943435aeeb8ef75b7dbd1181b233aca8b98946ea003a628461a8344d04a326c0b2f4587c20c2589d419c09e5f76b34e5
6
+ metadata.gz: 127e46cc938fe890f04ae0ad6b68b2ea0d2643ab7ca27c0855586291f363853cfb3151b0d88860d1f02f364f1430c42bef6f2c35880b6a8cc9504b5c7962b23c
7
+ data.tar.gz: c0e1fb812a023c8ecdfcf47a701dfc2fa4b959c9a63527109b4367a960f6261d5f3a35daa0415fe706ef0f3a66c4097a103bbd0a7d1ecc25b32872191013da46
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
@@ -183,13 +253,28 @@ All commands support these options:
183
253
  --host, -h HOST # BMC hostname or IP address
184
254
  --username, -u USER # BMC username
185
255
  --password, -p PASS # BMC password
186
- --vendor VENDOR # Force specific vendor (dell, supermicro, etc.)
256
+ --vendor, -v VENDOR # Force specific vendor (dell, supermicro, etc.)
187
257
  --port PORT # BMC port (default: 443)
258
+ --config, -c FILE # Read options from a YAML config file
188
259
  --json # Output in JSON format
189
- --verbose, -v # Enable verbose output (repeat for more verbosity)
190
- --no-verify-ssl # Skip SSL certificate verification
260
+ --verbose # Application-level progress messages
261
+ --debug [N] # HTTP-level debug output (default 2, see below)
262
+ --insecure # Skip SSL certificate verification (default: true)
191
263
  ```
192
264
 
265
+ `--verbose` and `--debug` set the same verbosity level, and `--debug` wins:
266
+
267
+ | Level | Flag | Output |
268
+ |-------|---------------|-----------------------------------------------------|
269
+ | 0 | (none) | Results only |
270
+ | 1 | `--verbose` | Progress messages from the library |
271
+ | 2 | `--debug` | Adds the HTTP request and response log |
272
+ | 3 | `--debug 3` | Adds request and response bodies, and call sites |
273
+
274
+ The `Authorization` header and password fields are filtered out of the log, but
275
+ level 3 prints request and response bodies, which can carry other sensitive
276
+ data - read it before pasting it into an issue.
277
+
193
278
  ### Output Formats
194
279
 
195
280
  #### Default (Human-Readable)
@@ -391,7 +476,7 @@ client.mount_iso_and_boot("http://example.com/os.iso")
391
476
  options = client.boot_options
392
477
 
393
478
  # Set one-time boot
394
- client.set_boot_override("Pxe", persistent: false)
479
+ client.set_boot_override("Pxe", persistence: 'Once')
395
480
 
396
481
  # Quick boot methods
397
482
  client.boot_to_pxe
@@ -457,8 +542,10 @@ Radfish::Client.new(
457
542
  use_ssl: true, # Use HTTPS
458
543
  verify_ssl: false, # Verify certificates
459
544
  direct_mode: false, # Use Basic Auth instead of sessions
460
- retry_count: 3, # Retry failed requests
461
- retry_delay: 1, # Initial delay between retries
545
+ retry_count: 3, # Retries after the first attempt
546
+ retry_delay: 1, # Initial delay between retries, doubled each time
547
+ retry_methods: nil, # Defaults to idempotent methods only
548
+ max_redirects: 3, # Same-endpoint redirects to follow (0 disables)
462
549
  verbosity: 0 # Debug output level (0-3)
463
550
  )
464
551
  ```
@@ -468,11 +555,14 @@ Radfish::Client.new(
468
555
  Enable verbose output:
469
556
 
470
557
  ```ruby
471
- client.verbosity = 1 # Basic debug info
472
- client.verbosity = 2 # Include request/response details
473
- client.verbosity = 3 # Include stack traces
558
+ client.verbosity = 1 # Progress messages from the library
559
+ client.verbosity = 2 # Adds the HTTP request and response log
560
+ client.verbosity = 3 # Adds request and response bodies, and call sites
474
561
  ```
475
562
 
563
+ These are the same levels the CLI sets with `--verbose` and `--debug N`. The
564
+ `Authorization` header and password fields are filtered out of the log.
565
+
476
566
  ## Supported Vendors
477
567
 
478
568
  Currently supported:
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radfish
4
+ # Boot configuration facade. Reached as +client.boot+.
5
+ #
6
+ # The vendor-neutral entry point for the boot mechanics that used to be hand-rolled in raw
7
+ # Redfish by callers (reading BootSources, disabling stale UEFI placeholders, scheduling the
8
+ # config job). The heavy lifting lives in the adapter; this object just exposes it cleanly and
9
+ # keeps +to_h+ pointing at the underlying boot configuration for backward compatibility.
10
+ class BootInfo
11
+ attr_reader :client
12
+
13
+ def initialize(client)
14
+ @client = client
15
+ end
16
+
17
+ # The still-enabled stale UEFI boot placeholders (Dell: "Unknown.Unknown.*"). [] when the
18
+ # adapter has nothing to report. Pass +match:+ to target a different set of entries.
19
+ def stale_uefi_entries(match: nil)
20
+ require_adapter!(:stale_uefi_boot_entries)
21
+ match ? adapter.stale_uefi_boot_entries(match: match) : adapter.stale_uefi_boot_entries
22
+ end
23
+
24
+ # Disable the matched UEFI boot entries via a config job and return the names disabled.
25
+ # With no +match:+ the adapter's default (the stale placeholders) applies. The adapter drains
26
+ # any pending LC config job first, so this never trips LC068.
27
+ def disable_entries(match: nil, **opts)
28
+ require_adapter!(:disable_boot_entries)
29
+ match ? adapter.disable_boot_entries(match: match, **opts) : adapter.disable_boot_entries(**opts)
30
+ end
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
+
54
+ # Underlying boot configuration hash (BootSourceOverride*, boot order, ...).
55
+ def to_h
56
+ adapter.boot_config
57
+ end
58
+ alias config to_h
59
+
60
+ private
61
+
62
+ def adapter
63
+ @client.adapter
64
+ end
65
+
66
+ def require_adapter!(method)
67
+ return if adapter.respond_to?(method)
68
+
69
+ raise NotImplementedError, "#{@client.vendor_name} adapter does not support ##{method}"
70
+ end
71
+ end
72
+ end
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 options[:verbose]
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 options[:verbose]
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
- client.verbosity = 1 if opts[:verbose]
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)
@@ -127,7 +127,67 @@ module Radfish
127
127
  def power
128
128
  @power ||= PowerInfo.new(self)
129
129
  end
130
-
130
+
131
+ # Boot facade: client.boot.stale_uefi_entries / client.boot.disable_entries(match:).
132
+ def boot
133
+ @boot ||= BootInfo.new(self)
134
+ end
135
+
136
+ # Live BMC power state (e.g. "On"/"Off"), read straight from the adapter. Replaces callers
137
+ # reaching through the adapter to the vendor client's own power-state call.
138
+ def power_state
139
+ @adapter.power_status
140
+ end
141
+
142
+ # Canonical Redfish BootProgress order (normalized to snake_case), earliest to latest. Used to
143
+ # decide when a host has reached OR passed a requested state.
144
+ BOOT_PROGRESS_ORDER = %i[
145
+ none
146
+ primary_processor_initialization_started
147
+ bus_initialization_started
148
+ memory_initialization_started
149
+ secondary_processor_initialization_started
150
+ pci_resource_config_started
151
+ system_hardware_initialization_complete
152
+ setup_entered
153
+ os_boot_started
154
+ os_running
155
+ ].freeze
156
+
157
+ # Normalized last BootProgress state (a snake_case symbol), or nil when the BMC omits
158
+ # BootProgress entirely (iDRAC8). nil means "cannot observe", never "not running".
159
+ def boot_progress
160
+ return nil unless @adapter.respond_to?(:boot_progress)
161
+
162
+ @adapter.boot_progress
163
+ end
164
+
165
+ # Wait until the host reaches (or passes) +target+ BootProgress, bounded by +timeout+ or, when
166
+ # not given, the adapter's per-model POST ceiling. Returns the observed state on success, or nil
167
+ # when the BMC does not report BootProgress (nothing to wait on -- the caller uses other signals).
168
+ # Raises Radfish::BootProgressTimeout on a stall.
169
+ def wait_for_boot_progress(target, timeout: nil, poll: 20)
170
+ target = target.to_sym
171
+ ceiling = timeout || (@adapter.respond_to?(:boot_progress_ceiling) ? @adapter.boot_progress_ceiling(target) : 900)
172
+ deadline = Time.now + ceiling
173
+ target_idx = BOOT_PROGRESS_ORDER.index(target)
174
+
175
+ loop do
176
+ state = boot_progress
177
+ return nil if state.nil? # BMC omits BootProgress (iDRAC8): unobservable, not a failure
178
+ return state if state == target
179
+
180
+ state_idx = BOOT_PROGRESS_ORDER.index(state)
181
+ return state if target_idx && state_idx && state_idx >= target_idx
182
+
183
+ if Time.now > deadline
184
+ raise BootProgressTimeout,
185
+ "BootProgress did not reach #{target.inspect} within #{ceiling}s (last: #{state.inspect})"
186
+ end
187
+ sleep poll
188
+ end
189
+ end
190
+
131
191
  def thermal
132
192
  @thermal ||= ThermalInfo.new(self)
133
193
  end
@@ -155,4 +155,4 @@ module Radfish
155
155
  end
156
156
  end
157
157
  end
158
- end
158
+ end
@@ -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, persistent: false)
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
 
@@ -34,7 +34,16 @@ module Radfish
34
34
  def boot_to_cd
35
35
  raise NotImplementedError, "Adapter must implement #boot_to_cd"
36
36
  end
37
-
37
+
38
+ # Convenience: one-time boot to the virtual CD. Default delegates to the adapter's standard
39
+ # Redfish boot override (boot_to_cd, which defaults to a one-time override). Adapters with a
40
+ # more reliable vendor path (e.g. Dell's SCP ServerBoot) override this. `reboot:` is accepted
41
+ # for signature parity with those overrides; the default does not reboot -- the caller cycles
42
+ # power (the app's install_os! does).
43
+ def set_one_time_cd_boot(reboot: false)
44
+ boot_to_cd
45
+ end
46
+
38
47
  def boot_to_usb
39
48
  raise NotImplementedError, "Adapter must implement #boot_to_usb"
40
49
  end
@@ -44,4 +53,4 @@ module Radfish
44
53
  end
45
54
  end
46
55
  end
47
- end
56
+ end
@@ -108,9 +108,7 @@ module Radfish
108
108
  false
109
109
  end
110
110
  end
111
-
112
- private
113
-
111
+
114
112
  def verbosity
115
113
  client.verbosity
116
114
  end
@@ -11,12 +11,27 @@ 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
+
14
27
  attr_reader :host, :port, :use_ssl, :verify_ssl
15
- attr_accessor :username, :password, :verbosity, :retry_count, :retry_delay
28
+ attr_accessor :username, :password, :verbosity, :retry_count, :retry_delay,
29
+ :retry_methods, :max_redirects
16
30
 
17
31
  def initialize(host:, port: 443, use_ssl: true, verify_ssl: false,
18
32
  username: nil, password: nil, verbosity: 0,
19
- retry_count: 3, retry_delay: 1, **options)
33
+ retry_count: 3, retry_delay: 1,
34
+ retry_methods: IDEMPOTENT_METHODS, max_redirects: 3, **options)
20
35
  @host = host
21
36
  @port = port
22
37
  @use_ssl = use_ssl
@@ -26,6 +41,8 @@ module Radfish
26
41
  @verbosity = verbosity
27
42
  @retry_count = retry_count
28
43
  @retry_delay = retry_delay
44
+ @retry_methods = retry_methods
45
+ @max_redirects = max_redirects
29
46
  @options = options
30
47
  end
31
48
 
@@ -54,8 +71,12 @@ module Radfish
54
71
  request(:delete, path, headers: headers, **options)
55
72
  end
56
73
 
57
- def request(method, path, body: nil, headers: {}, auth: true, timeout: nil, **options)
74
+ # max_redirects overrides the client default for one call; pass 0 to get the
75
+ # 3xx response itself rather than what it points at.
76
+ def request(method, path, body: nil, headers: {}, auth: true, timeout: nil,
77
+ max_redirects: nil, **options)
58
78
  debug "Starting HTTP #{method.upcase} request to #{path}", 2, :yellow
79
+ redirects_left = max_redirects.nil? ? @max_redirects.to_i : max_redirects.to_i
59
80
 
60
81
  # Add host header if specified (needed for SSH tunnels to iDRAC)
61
82
  if @options[:host_header]
@@ -90,6 +111,18 @@ module Radfish
90
111
 
91
112
  debug "Request completed with status: #{response.status}", 2, :green
92
113
 
114
+ if redirects_left > 0 && redirect?(method, response)
115
+ target = safe_redirect_path(response['location'])
116
+
117
+ if target.nil?
118
+ debug "Not following redirect to #{response['location'].inspect} - it leaves #{base_url}", 1, :yellow
119
+ else
120
+ debug "Following redirect to #{target}, #{redirects_left - 1} left", 2, :yellow
121
+ return request(method, target, body: body, headers: headers, auth: auth,
122
+ timeout: timeout, max_redirects: redirects_left - 1, **options)
123
+ end
124
+ end
125
+
93
126
  response
94
127
  rescue Faraday::ConnectionFailed => e
95
128
  debug "Connection failed: #{e.message}", 1, :red
@@ -127,8 +160,42 @@ module Radfish
127
160
  raise e
128
161
  end
129
162
 
163
+ def redirect?(method, response)
164
+ REDIRECT_METHODS.include?(method) && REDIRECT_STATUSES.include?(response.status)
165
+ end
166
+
167
+ # The path (with query) to request for +location+, or nil when we must not
168
+ # follow it. Every request we make carries credentials -- Basic auth here, an
169
+ # X-Auth-Token on the adapters' authenticated_request -- and Faraday honours an
170
+ # absolute URL by replacing the host, so a BMC answering with
171
+ # "Location: https://elsewhere/" would be handed those credentials. Accept a
172
+ # relative path, or an absolute URL for this same endpoint, and return only the
173
+ # path so the request stays on this connection.
174
+ #
175
+ # Note: with an SSH tunnel (host_header set) an absolute redirect to the BMC's
176
+ # own name is refused, since that name is not the host we connect to. Relative
177
+ # redirects are unaffected.
178
+ def safe_redirect_path(location)
179
+ location = location.to_s
180
+ return nil if location.empty?
181
+
182
+ uri = URI.parse(location)
183
+ return nil if uri.path.to_s.empty?
184
+ return nil unless uri.host.nil? || same_endpoint?(uri)
185
+
186
+ uri.query ? "#{uri.path}?#{uri.query}" : uri.path
187
+ rescue URI::InvalidURIError
188
+ nil
189
+ end
190
+
130
191
  private
131
192
 
193
+ def same_endpoint?(uri)
194
+ uri.host == host &&
195
+ uri.port == port &&
196
+ uri.scheme == (use_ssl ? 'https' : 'http')
197
+ end
198
+
132
199
  def connection(auth: true)
133
200
  @connections ||= {}
134
201
  cache_key = auth ? :with_auth : :without_auth
@@ -182,8 +249,8 @@ module Radfish
182
249
  Faraday::TimeoutError,
183
250
  Faraday::RetriableResponse
184
251
  ],
185
- methods: [:get, :put, :delete, :post, :patch],
186
- retry_statuses: [408, 429, 500, 502, 503, 504]
252
+ methods: retry_methods,
253
+ retry_statuses: RETRY_STATUSES
187
254
  # Removed retry_block to debug ArgumentError - can add back later
188
255
  }
189
256
 
@@ -192,8 +259,8 @@ module Radfish
192
259
  faraday.options.open_timeout = 10
193
260
 
194
261
  # Add logging if verbose
195
- if verbosity > 0
196
- faraday.response :logger, Logger.new(STDOUT), { bodies: verbosity >= 2 } do |logger|
262
+ if verbosity >= 2
263
+ faraday.response :logger, Logger.new(STDOUT), { bodies: verbosity >= 3 } do |logger|
197
264
  logger.filter(/(Authorization: Basic )([^,\n]+)/, '\1[FILTERED]')
198
265
  logger.filter(/(Password"=>"?)([^,"]+)/, '\1[FILTERED]')
199
266
  logger.filter(/("password":\s*")([^"]+)/, '\1[FILTERED]')
@@ -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
- if response.status == 200
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Radfish
4
- VERSION = "0.2.10"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/radfish.rb CHANGED
@@ -30,6 +30,10 @@ module Radfish
30
30
  class TaskTimeoutError < TaskError; end
31
31
  class TaskFailedError < TaskError; end
32
32
 
33
+ # Raised by Client#wait_for_boot_progress when a host does not reach the requested
34
+ # BootProgress state within its (per-model) ceiling.
35
+ class BootProgressTimeout < TimeoutError; end
36
+
33
37
  module Debuggable
34
38
  def debug(message, level = 1, color = :light_cyan)
35
39
  return unless respond_to?(:verbosity) && verbosity >= level
@@ -54,8 +58,10 @@ module Radfish
54
58
  Client.connect(**options, &block)
55
59
  end
56
60
 
57
- def detect_vendor(host:, username:, password:, **options)
58
- VendorDetector.new(host: host, username: username, password: password, **options).detect
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
59
65
  end
60
66
 
61
67
  def register_adapter(vendor, adapter_class)
@@ -90,6 +96,7 @@ require_relative 'radfish/vendor_detector'
90
96
  require_relative 'radfish/system_info'
91
97
  require_relative 'radfish/bmc_info'
92
98
  require_relative 'radfish/power_info'
99
+ require_relative 'radfish/boot_info'
93
100
  require_relative 'radfish/thermal_info'
94
101
  require_relative 'radfish/pci_info'
95
102
  require_relative 'radfish/controller'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: radfish
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.10
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Siegel
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-20 00:00:00.000000000 Z
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -166,6 +166,7 @@ files:
166
166
  - exe/radfish
167
167
  - lib/radfish.rb
168
168
  - lib/radfish/bmc_info.rb
169
+ - lib/radfish/boot_info.rb
169
170
  - lib/radfish/cli.rb
170
171
  - lib/radfish/cli/base.rb
171
172
  - lib/radfish/client.rb