Skip to content

Commit d24caf0

Browse files
[release-2.12] MTV-5829 | Add retry backoff and auth detection to Hyper-V collector (#7232)
**Backport:** #7230 The Hyper-V collector retried failed runs with no delay, spamming the host with WinRM requests when credentials were incorrect or basic auth was not enabled. - Add RetryInterval (5s) sleep on run() failure in Start() loop, matching the pattern used by oVirt, OpenStack, and OVA collectors. - Introduce ErrUnauthorized sentinel error in the driver package, wrapping WinRM HTTP 401/403 responses so callers use errors.Is() instead of fragile string matching. - Update Test() to execute a real WinRM command (IsAlive) and return http.StatusUnauthorized on auth failure, triggering ConnectionAuthFailed in the provider controller. - Propagate auth errors from SMB prefix discovery in Connect() Ref: https://redhat.atlassian.net/browse/MTV-5829 Resolves: MTV-5829 Signed-off-by: Elad Hazan <ehazan@redhat.com> Co-authored-by: Elad Hazan <ehazan@redhat.com>
1 parent af0a4fa commit d24caf0

5 files changed

Lines changed: 211 additions & 15 deletions

File tree

pkg/controller/provider/container/hyperv/client.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package hyperv
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"net"
89
"net/http"
@@ -80,7 +81,10 @@ func (r *Client) Connect(provider *api.Provider) (err error) {
8081

8182
if r.smbUrl != "" {
8283
if pErr := r.discoverSMBWindowsPrefix(); pErr != nil {
83-
r.Log.Error(pErr, "Failed to discover SMB Windows prefix, will retry")
84+
if errors.Is(pErr, driver.ErrUnauthorized) {
85+
return fmt.Errorf("SMB discovery auth failed: %w", pErr)
86+
}
87+
r.Log.Info("SMB Windows prefix not yet discovered, will attempt on next reconnect")
8488
}
8589
}
8690

pkg/controller/provider/container/hyperv/collector.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package hyperv
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
"net/http"
68
liburl "net/url"
79
"os"
810
libpath "path"
@@ -12,6 +14,7 @@ import (
1214
api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1"
1315
hvutil "github.com/kubev2v/forklift/pkg/controller/hyperv"
1416
model "github.com/kubev2v/forklift/pkg/controller/provider/model/hyperv"
17+
"github.com/kubev2v/forklift/pkg/lib/hyperv/driver"
1518
libmodel "github.com/kubev2v/forklift/pkg/lib/inventory/model"
1619
"github.com/kubev2v/forklift/pkg/lib/logging"
1720
core "k8s.io/api/core/v1"
@@ -161,9 +164,21 @@ func (r *Collector) HasParity() bool {
161164
return r.parity
162165
}
163166

164-
// Test connect/logout.
165-
func (r *Collector) Test() (_ int, err error) {
166-
err = r.client.Connect(r.provider)
167+
// Test validates connectivity and credentials against the Hyper-V host.
168+
func (r *Collector) Test() (status int, err error) {
169+
if err = r.client.Connect(r.provider); err != nil {
170+
if errors.Is(err, driver.ErrUnauthorized) {
171+
status = http.StatusUnauthorized
172+
}
173+
return
174+
}
175+
if _, err = r.client.driver.IsAlive(); err != nil {
176+
if errors.Is(err, driver.ErrUnauthorized) {
177+
status = http.StatusUnauthorized
178+
}
179+
return
180+
}
181+
r.log.Info("Connected to Hyper-V host via WinRM/HTTPS.")
167182
return
168183
}
169184

@@ -191,11 +206,17 @@ func (r *Collector) Start() error {
191206
r.log.Info("Stopped.")
192207
}()
193208
for {
194-
if !ctx.canceled() {
195-
_ = r.run(&ctx)
196-
} else {
209+
if ctx.canceled() {
197210
return
198211
}
212+
if err := r.run(&ctx); err != nil {
213+
r.log.Error(err, "Run failed.", "retry", RetryInterval)
214+
select {
215+
case <-ctx.ctx.Done():
216+
return
217+
case <-time.After(RetryInterval):
218+
}
219+
}
199220
}
200221
}
201222

@@ -218,12 +239,6 @@ func (r *Collector) run(ctx *Context) (err error) {
218239
r.startTime = time.Now()
219240
r.phase = Started
220241

221-
defer func() {
222-
if err != nil {
223-
r.log.Error(err, "Run failed.")
224-
}
225-
}()
226-
227242
// Connect directly to HyperV host via WinRM using Secret credentials
228243
err = r.client.Connect(r.provider)
229244
if err != nil {

pkg/lib/hyperv/driver/errors.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package driver
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"regexp"
8+
"strconv"
9+
)
10+
11+
// ErrUnauthorized indicates a WinRM authentication / authorization failure.
12+
var ErrUnauthorized = errors.New("hyperv: unauthorized")
13+
14+
// winrmHTTPStatus extracts the numeric HTTP status from the winrm library's
15+
// known error formats: "http response error: <code> - ..." and "http error <code>: ...".
16+
var winrmHTTPStatus = regexp.MustCompile(`http (?:response )?error[:\s]+(\d{3})`)
17+
18+
// httpStatus extracts the HTTP status code from a WinRM error message.
19+
func httpStatus(err error) (int, bool) {
20+
if err == nil {
21+
return 0, false
22+
}
23+
if m := winrmHTTPStatus.FindStringSubmatch(err.Error()); len(m) == 2 {
24+
if code, convErr := strconv.Atoi(m[1]); convErr == nil {
25+
return code, true
26+
}
27+
}
28+
return 0, false
29+
}
30+
31+
// WrapCommandError inspects a WinRM command error.
32+
func WrapCommandError(err error) error {
33+
if err == nil {
34+
return nil
35+
}
36+
if code, ok := httpStatus(err); ok {
37+
if code == http.StatusUnauthorized || code == http.StatusForbidden {
38+
return fmt.Errorf("%w: %w", ErrUnauthorized, err)
39+
}
40+
}
41+
return err
42+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package driver
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"testing"
8+
)
9+
10+
func Test_httpStatus(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
err error
14+
wantCode int
15+
wantOK bool
16+
}{
17+
{
18+
name: "nil error",
19+
err: nil,
20+
wantOK: false,
21+
},
22+
{
23+
name: "unrelated error",
24+
err: errors.New("connection refused"),
25+
wantOK: false,
26+
},
27+
{
28+
name: "winrm basic auth 401",
29+
err: fmt.Errorf("http response error: 401 - invalid content type"),
30+
wantCode: http.StatusUnauthorized,
31+
wantOK: true,
32+
},
33+
{
34+
name: "winrm cert auth 401",
35+
err: fmt.Errorf("http error 401: Unauthorized"),
36+
wantCode: http.StatusUnauthorized,
37+
wantOK: true,
38+
},
39+
{
40+
name: "403 forbidden",
41+
err: fmt.Errorf("http response error: 403 - access denied"),
42+
wantCode: http.StatusForbidden,
43+
wantOK: true,
44+
},
45+
{
46+
name: "500 server error",
47+
err: fmt.Errorf("http response error: 500 - internal"),
48+
wantCode: http.StatusInternalServerError,
49+
wantOK: true,
50+
},
51+
{
52+
name: "wrapped winrm error",
53+
err: fmt.Errorf("WinRM command failed: %w", fmt.Errorf("http response error: 401 - invalid content type")),
54+
wantCode: http.StatusUnauthorized,
55+
wantOK: true,
56+
},
57+
{
58+
name: "double-wrapped",
59+
err: fmt.Errorf("outer: %w", fmt.Errorf("WinRM command failed: %w", fmt.Errorf("http response error: 401 - x"))),
60+
wantCode: http.StatusUnauthorized,
61+
wantOK: true,
62+
},
63+
}
64+
65+
for _, tc := range tests {
66+
t.Run(tc.name, func(t *testing.T) {
67+
code, ok := httpStatus(tc.err)
68+
if ok != tc.wantOK {
69+
t.Errorf("httpStatus() ok = %v, want %v", ok, tc.wantOK)
70+
}
71+
if code != tc.wantCode {
72+
t.Errorf("httpStatus() code = %d, want %d", code, tc.wantCode)
73+
}
74+
})
75+
}
76+
}
77+
78+
func TestWrapCommandError(t *testing.T) {
79+
tests := []struct {
80+
name string
81+
err error
82+
wantNil bool
83+
wantIsAuth bool
84+
}{
85+
{
86+
name: "nil",
87+
err: nil,
88+
wantNil: true,
89+
},
90+
{
91+
name: "unrelated error passes through",
92+
err: errors.New("timeout"),
93+
wantIsAuth: false,
94+
},
95+
{
96+
name: "401 becomes ErrUnauthorized",
97+
err: fmt.Errorf("http response error: 401 - invalid content type"),
98+
wantIsAuth: true,
99+
},
100+
{
101+
name: "403 becomes ErrUnauthorized",
102+
err: fmt.Errorf("http response error: 403 - forbidden"),
103+
wantIsAuth: true,
104+
},
105+
{
106+
name: "500 does not become ErrUnauthorized",
107+
err: fmt.Errorf("http response error: 500 - internal"),
108+
wantIsAuth: false,
109+
},
110+
{
111+
name: "wrapped 401",
112+
err: fmt.Errorf("WinRM command failed: %w", fmt.Errorf("http response error: 401 - x")),
113+
wantIsAuth: true,
114+
},
115+
}
116+
117+
for _, tc := range tests {
118+
t.Run(tc.name, func(t *testing.T) {
119+
result := WrapCommandError(tc.err)
120+
if tc.wantNil {
121+
if result != nil {
122+
t.Fatalf("WrapCommandError(nil) = %v, want nil", result)
123+
}
124+
return
125+
}
126+
if result == nil {
127+
t.Fatal("WrapCommandError() returned nil for non-nil input")
128+
}
129+
isAuth := errors.Is(result, ErrUnauthorized)
130+
if isAuth != tc.wantIsAuth {
131+
t.Errorf("errors.Is(result, ErrUnauthorized) = %v, want %v (err=%v)", isAuth, tc.wantIsAuth, result)
132+
}
133+
})
134+
}
135+
}

pkg/lib/hyperv/driver/winrm.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func (d *WinRMDriver) Connect() error {
7878
return fmt.Errorf("failed to create WinRM client: %w", err)
7979
}
8080
d.client = client
81-
log.Info("Connected to Hyper-V host via WinRM/HTTPS", "host", d.host, "port", d.port, "insecureSkipVerify", d.insecureSkipVerify)
81+
log.Info("WinRM client initialized.", "host", d.host, "port", d.port, "insecureSkipVerify", d.insecureSkipVerify)
8282
return nil
8383
}
8484

@@ -123,7 +123,7 @@ func (d *WinRMDriver) ExecuteCommandWithTimeout(command string, timeout time.Dur
123123

124124
stdout, stderr, exitCode, err := d.client.RunWithContextWithString(ctx, command, "")
125125
if err != nil {
126-
return "", fmt.Errorf("WinRM command failed: %w", err)
126+
return "", WrapCommandError(fmt.Errorf("WinRM command failed: %w", err))
127127
}
128128

129129
if exitCode != 0 {

0 commit comments

Comments
 (0)