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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56ac8a4bd0fa74d4925bd369825c0d7df1b87e77acea9a41d1327461a058e2e4
4
- data.tar.gz: 5089df7444609d31fd193e8d6c022640ec57cbda96baf26c1df920eeb17bc3ab
3
+ metadata.gz: 28b19abfcfc47b88d31ad43a17e67b9f0de71ac358c44bdb8e7e6f91b80a775f
4
+ data.tar.gz: d6e34c43743f79bfbfbb874545deedf1a487bfdfd6c7111683baebad257b5f45
5
5
  SHA512:
6
- metadata.gz: 10cdabeb27695b1a66aeb6531fa83da737b2a66e9f6ab24505a870797968ec5de49a388299627a954f1641e45ef356182535dcd7e4916bb2e8469b447c1d7dc4
7
- data.tar.gz: bb95d3be33e59529b53231948763f60803f637353a707395cbc674aa898932696868dae481108fc64545055ed26072a7fda308c948214b770635618ab850b8dc
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 # Force specific vendor (dell, supermicro, etc.)
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, -v # Enable verbose output (repeat for more verbosity)
190
- --no-verify-ssl # Skip SSL certificate verification
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", persistent: false)
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, # Retry failed requests
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 # Basic debug info
472
- client.verbosity = 2 # Include request/response details
473
- client.verbosity = 3 # Include stack traces
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:
@@ -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 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)
@@ -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
 
@@ -53,4 +53,4 @@ module Radfish
53
53
  end
54
54
  end
55
55
  end
56
- end
56
+ end
@@ -14,9 +14,14 @@ module Radfish
14
14
  end
15
15
 
16
16
  def connection
17
- @connection ||= Faraday.new(url: client.base_url, ssl: { verify: client.verify_ssl }) do |faraday|
18
- faraday.request :url_encoded
19
- faraday.adapter Faraday.default_adapter
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
- headers['Host'] = client.host_header if client.host_header
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. Token: #{@x_auth_token ? @x_auth_token[0..10] + '...' : 'nil'}", 1, :green
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 Faraday::Error => e
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
- headers['Host'] = client.host_header if client.host_header
76
-
79
+
77
80
  begin
78
- response = connection.delete("/redfish/v1/SessionService/Sessions/#{@session_id}", nil, headers)
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 Faraday::Error => e
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
- headers['Host'] = client.host_header if client.host_header
103
-
105
+
104
106
  begin
105
- response = connection.get("/redfish/v1/SessionService/Sessions/#{@session_id}", nil, headers)
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
@@ -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, **options)
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
- def request(method, path, body: nil, headers: {}, auth: true, timeout: nil, **options)
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: [:get, :put, :delete, :post, :patch],
186
- retry_statuses: [408, 429, 500, 502, 503, 504]
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 > 0
196
- faraday.response :logger, Logger.new(STDOUT), { bodies: verbosity >= 2 } do |logger|
197
- logger.filter(/(Authorization: Basic )([^,\n]+)/, '\1[FILTERED]')
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
- 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.12"
4
+ VERSION = "0.3.1"
5
5
  end
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).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
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.2.12
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: 1980-01-02 00:00:00.000000000 Z
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.6.9
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: []