Skip to content

Commit 0cc57ff

Browse files
DanNiEShclaude
andcommitted
Add automatic reconnect on Ironic authentication failure
When gophercloud's built-in token refresh (ReauthFunc) fails — e.g. due to a network interruption or Keystone being temporarily unavailable — the Ironic client was left permanently stuck with a stale token until the pod restarted. This adds a fallback that detects 401/reauth errors, recreates the service client from scratch, and retries the operation once. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3ad9e1a commit 0cc57ff

4 files changed

Lines changed: 228 additions & 11 deletions

File tree

cmd/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ limitations under the License.
1717
package main
1818

1919
import (
20+
"context"
2021
"crypto/tls"
2122
"flag"
2223
"fmt"
2324
"os"
2425
"path/filepath"
2526
"strconv"
27+
"time"
2628

2729
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
2830
// to ensure that exec-entrypoint and run can make use of them.
@@ -233,7 +235,9 @@ func main() {
233235

234236
// Ironic client for bare metal management
235237
var ironicClient *ironic.Client
236-
if ironicClient, err = ironic.NewClient(); err != nil {
238+
ironicCtx, ironicCancel := context.WithTimeout(context.Background(), 30*time.Second)
239+
defer ironicCancel()
240+
if ironicClient, err = ironic.NewClient(ironicCtx); err != nil {
237241
setupLog.Error(err, "failed to create Ironic client")
238242
os.Exit(1)
239243
}

internal/ironic/client.go

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ package ironic
1919

2020
import (
2121
"context"
22+
"errors"
2223
"fmt"
24+
"net/http"
2325

2426
"github.com/gophercloud/gophercloud/v2"
2527
"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/nodes"
2628
"github.com/gophercloud/utils/v2/openstack/clientconfig"
29+
ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
2730
)
2831

2932
// NodeClient is the interface for interacting with Ironic nodes.
@@ -36,7 +39,8 @@ type NodeClient interface {
3639
// Client talks to Ironic over REST via gophercloud.
3740
type (
3841
Client struct {
39-
serviceClient *gophercloud.ServiceClient
42+
serviceClient *gophercloud.ServiceClient
43+
newServiceClient func(ctx context.Context) (*gophercloud.ServiceClient, error)
4044
}
4145

4246
TargetPowerState struct {
@@ -56,24 +60,71 @@ var (
5660
SoftRebooting = TargetPowerState{nodes.SoftRebooting}
5761
)
5862

59-
// NewClient creates an Ironic client with no-auth (typical for in-cluster Ironic with no Keystone).
60-
func NewClient() (*Client, error) {
61-
client, err := clientconfig.NewServiceClient(context.TODO(), "baremetal", nil)
63+
// NewClient creates an Ironic client configured via clouds.yaml or OS_* environment variables.
64+
func NewClient(ctx context.Context) (*Client, error) {
65+
factory := func(ctx context.Context) (*gophercloud.ServiceClient, error) {
66+
return clientconfig.NewServiceClient(ctx, "baremetal", nil)
67+
}
68+
sc, err := factory(ctx)
6269
if err != nil {
6370
return nil, fmt.Errorf("failed to create baremetal client: %w", err)
6471
}
65-
if client.Endpoint == "" {
72+
if sc.Endpoint == "" {
6673
return nil, fmt.Errorf("baremetal client has no endpoint configured")
6774
}
6875
return &Client{
69-
serviceClient: client,
76+
serviceClient: sc,
77+
newServiceClient: factory,
7078
}, nil
7179
}
7280

81+
func isAuthError(err error) bool {
82+
if err == nil {
83+
return false
84+
}
85+
if gophercloud.ResponseCodeIs(err, http.StatusUnauthorized) {
86+
return true
87+
}
88+
var errReauth gophercloud.ErrUnableToReauthenticate
89+
if errors.As(err, &errReauth) {
90+
return true
91+
}
92+
var errAfterReauth gophercloud.ErrErrorAfterReauthentication
93+
return errors.As(err, &errAfterReauth)
94+
}
95+
96+
func (c *Client) reconnect(ctx context.Context) error {
97+
log := ctrllog.FromContext(ctx)
98+
log.Info("recreating ironic service client after authentication failure")
99+
sc, err := c.newServiceClient(ctx)
100+
if err != nil {
101+
log.Error(err, "failed to recreate ironic service client")
102+
return fmt.Errorf("failed to recreate baremetal client: %w", err)
103+
}
104+
if sc.Endpoint == "" {
105+
return fmt.Errorf("recreated baremetal client has no endpoint configured")
106+
}
107+
c.serviceClient = sc
108+
log.Info("ironic service client reconnected successfully", "endpoint", sc.Endpoint)
109+
return nil
110+
}
111+
73112
// GetNode fetches a node by UUID or name from Ironic.
74113
func (c *Client) GetNode(ctx context.Context, nodeID string) (*nodes.Node, error) {
114+
log := ctrllog.FromContext(ctx)
75115
node, err := nodes.Get(ctx, c.serviceClient, nodeID).Extract()
76116
if err != nil {
117+
if isAuthError(err) {
118+
log.Info("auth error on GetNode, attempting reconnect", "nodeID", nodeID, "error", err)
119+
if reconnErr := c.reconnect(ctx); reconnErr != nil {
120+
return nil, fmt.Errorf("get node %s: reconnect failed: %w", nodeID, reconnErr)
121+
}
122+
node, err = nodes.Get(ctx, c.serviceClient, nodeID).Extract()
123+
if err != nil {
124+
return nil, fmt.Errorf("get node %s after reconnect: %w", nodeID, err)
125+
}
126+
return node, nil
127+
}
77128
return nil, fmt.Errorf("get node %s: %w", nodeID, err)
78129
}
79130
return node, nil
@@ -91,8 +142,20 @@ func (c *Client) IsNodePowerTransitioning(node *nodes.Node) bool {
91142

92143
// SetPowerState requests power on or off for the node via Ironic.
93144
func (c *Client) SetPowerState(ctx context.Context, nodeID string, target TargetPowerState) error {
145+
log := ctrllog.FromContext(ctx)
94146
res := nodes.ChangePowerState(ctx, c.serviceClient, nodeID, nodes.PowerStateOpts{Target: target.powerstate})
95147
if err := res.ExtractErr(); err != nil {
148+
if isAuthError(err) {
149+
log.Info("auth error on SetPowerState, attempting reconnect", "nodeID", nodeID, "target", target.String(), "error", err)
150+
if reconnErr := c.reconnect(ctx); reconnErr != nil {
151+
return fmt.Errorf("failed to set power state on node %s: reconnect failed: %w", nodeID, reconnErr)
152+
}
153+
res = nodes.ChangePowerState(ctx, c.serviceClient, nodeID, nodes.PowerStateOpts{Target: target.powerstate})
154+
if err := res.ExtractErr(); err != nil {
155+
return fmt.Errorf("failed to set power state on node %s after reconnect: %w", nodeID, err)
156+
}
157+
return nil
158+
}
96159
return fmt.Errorf("failed to set power state on node %s: %w", nodeID, err)
97160
}
98161
return nil
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
Copyright 2026.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package ironic
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"net/http"
23+
24+
. "github.com/onsi/ginkgo/v2"
25+
. "github.com/onsi/gomega"
26+
27+
"github.com/gophercloud/gophercloud/v2"
28+
)
29+
30+
const (
31+
oldEndpoint = "http://old:6385/v1/"
32+
newEndpoint = "http://new:6385/v1/"
33+
)
34+
35+
var _ = Describe("isAuthError", func() {
36+
It("should return false for nil error", func() {
37+
Expect(isAuthError(nil)).To(BeFalse())
38+
})
39+
40+
It("should return false for generic errors", func() {
41+
Expect(isAuthError(fmt.Errorf("some random error"))).To(BeFalse())
42+
})
43+
44+
It("should return true for 401 ErrUnexpectedResponseCode", func() {
45+
err := gophercloud.ErrUnexpectedResponseCode{
46+
Actual: http.StatusUnauthorized,
47+
Expected: []int{http.StatusOK},
48+
}
49+
Expect(isAuthError(err)).To(BeTrue())
50+
})
51+
52+
It("should return false for 404 ErrUnexpectedResponseCode", func() {
53+
err := gophercloud.ErrUnexpectedResponseCode{
54+
Actual: http.StatusNotFound,
55+
Expected: []int{http.StatusOK},
56+
}
57+
Expect(isAuthError(err)).To(BeFalse())
58+
})
59+
60+
It("should return false for 500 ErrUnexpectedResponseCode", func() {
61+
err := gophercloud.ErrUnexpectedResponseCode{
62+
Actual: http.StatusInternalServerError,
63+
Expected: []int{http.StatusOK},
64+
}
65+
Expect(isAuthError(err)).To(BeFalse())
66+
})
67+
68+
It("should return true for ErrUnableToReauthenticate", func() {
69+
err := gophercloud.ErrUnableToReauthenticate{
70+
ErrOriginal: fmt.Errorf("original"),
71+
ErrReauth: fmt.Errorf("reauth failed"),
72+
}
73+
Expect(isAuthError(err)).To(BeTrue())
74+
})
75+
76+
It("should return true for ErrErrorAfterReauthentication", func() {
77+
err := gophercloud.ErrErrorAfterReauthentication{
78+
ErrOriginal: fmt.Errorf("still failing"),
79+
}
80+
Expect(isAuthError(err)).To(BeTrue())
81+
})
82+
83+
It("should return true for wrapped 401 error", func() {
84+
inner := gophercloud.ErrUnexpectedResponseCode{
85+
Actual: http.StatusUnauthorized,
86+
Expected: []int{http.StatusOK},
87+
}
88+
wrapped := fmt.Errorf("operation failed: %w", inner)
89+
Expect(isAuthError(wrapped)).To(BeTrue())
90+
})
91+
92+
It("should return false for wrapped non-auth error", func() {
93+
inner := gophercloud.ErrUnexpectedResponseCode{
94+
Actual: http.StatusNotFound,
95+
Expected: []int{http.StatusOK},
96+
}
97+
wrapped := fmt.Errorf("operation failed: %w", inner)
98+
Expect(isAuthError(wrapped)).To(BeFalse())
99+
})
100+
})
101+
102+
var _ = Describe("reconnect", func() {
103+
It("should swap the service client on success", func() {
104+
oldSC := &gophercloud.ServiceClient{Endpoint: oldEndpoint}
105+
newSC := &gophercloud.ServiceClient{Endpoint: newEndpoint}
106+
107+
c := &Client{
108+
serviceClient: oldSC,
109+
newServiceClient: func(context.Context) (*gophercloud.ServiceClient, error) {
110+
return newSC, nil
111+
},
112+
}
113+
114+
Expect(c.reconnect(context.Background())).To(Succeed())
115+
Expect(c.serviceClient).To(Equal(newSC))
116+
Expect(c.GetEndpoint()).To(Equal(newEndpoint))
117+
})
118+
119+
It("should return error when factory fails", func() {
120+
oldSC := &gophercloud.ServiceClient{Endpoint: oldEndpoint}
121+
122+
c := &Client{
123+
serviceClient: oldSC,
124+
newServiceClient: func(context.Context) (*gophercloud.ServiceClient, error) {
125+
return nil, fmt.Errorf("keystone is down")
126+
},
127+
}
128+
129+
err := c.reconnect(context.Background())
130+
Expect(err).To(HaveOccurred())
131+
Expect(err.Error()).To(ContainSubstring("keystone is down"))
132+
Expect(c.serviceClient).To(Equal(oldSC), "should keep old client on failure")
133+
})
134+
135+
It("should return error when new client has empty endpoint", func() {
136+
oldSC := &gophercloud.ServiceClient{Endpoint: oldEndpoint}
137+
138+
c := &Client{
139+
serviceClient: oldSC,
140+
newServiceClient: func(context.Context) (*gophercloud.ServiceClient, error) {
141+
return &gophercloud.ServiceClient{Endpoint: ""}, nil
142+
},
143+
}
144+
145+
err := c.reconnect(context.Background())
146+
Expect(err).To(HaveOccurred())
147+
Expect(err.Error()).To(ContainSubstring("no endpoint configured"))
148+
})
149+
})

internal/ironic/client_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package ironic_test
1818

1919
import (
20+
"context"
2021
"os"
2122
"testing"
2223

@@ -106,7 +107,7 @@ var _ = Describe("Client", func() {
106107
})
107108

108109
It("should return an error when no credentials are available", func() {
109-
client, err := ironic.NewClient()
110+
client, err := ironic.NewClient(context.Background())
110111
Expect(err).To(HaveOccurred())
111112
Expect(err.Error()).To(ContainSubstring("failed to create baremetal client"))
112113
Expect(client).To(BeNil())
@@ -121,14 +122,14 @@ var _ = Describe("Client", func() {
121122
})
122123

123124
It("should create a client successfully", func() {
124-
client, err := ironic.NewClient()
125+
client, err := ironic.NewClient(context.Background())
125126
Expect(err).NotTo(HaveOccurred())
126127
Expect(client).NotTo(BeNil())
127128
Expect(client.GetEndpoint()).NotTo(BeEmpty())
128129
})
129130

130131
It("should have a non-empty endpoint", func() {
131-
client, err := ironic.NewClient()
132+
client, err := ironic.NewClient(context.Background())
132133
Expect(err).NotTo(HaveOccurred())
133134

134135
endpoint := client.GetEndpoint()
@@ -145,7 +146,7 @@ var _ = Describe("Client", func() {
145146
})
146147

147148
It("should return a valid HTTP(S) endpoint", func() {
148-
client, err := ironic.NewClient()
149+
client, err := ironic.NewClient(context.Background())
149150
Expect(err).NotTo(HaveOccurred())
150151

151152
endpoint := client.GetEndpoint()

0 commit comments

Comments
 (0)