Skip to content

Commit c044e45

Browse files
authored
Merge pull request #296 from linode/more-robust-healthcheck
[feat] add linode token health check
2 parents 1f644a0 + 8b1e8df commit c044e45

File tree

8 files changed

+308
-18
lines changed

8 files changed

+308
-18
lines changed

cloud/linode/client/client.go

+21
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package client
44

55
import (
66
"context"
7+
"errors"
78
"fmt"
89
"net/http"
910
"os"
@@ -52,6 +53,8 @@ type Client interface {
5253
DeleteFirewall(ctx context.Context, fwid int) error
5354
GetFirewall(context.Context, int) (*linodego.Firewall, error)
5455
UpdateFirewallRules(context.Context, int, linodego.FirewallRuleSet) (*linodego.FirewallRuleSet, error)
56+
57+
GetProfile(ctx context.Context) (*linodego.Profile, error)
5558
}
5659

5760
// linodego.Client implements Client
@@ -73,3 +76,21 @@ func New(token string, timeout time.Duration) (*linodego.Client, error) {
7376
klog.V(3).Infof("Linode client created with default timeout of %v", timeout)
7477
return client, nil
7578
}
79+
80+
func CheckClientAuthenticated(ctx context.Context, client Client) (bool, error) {
81+
_, err := client.GetProfile(ctx)
82+
if err == nil {
83+
return true, nil
84+
}
85+
86+
var linodeErr *linodego.Error
87+
if !errors.As(err, &linodeErr) {
88+
return false, err
89+
}
90+
91+
if linodego.ErrHasStatus(err, http.StatusUnauthorized) {
92+
return false, nil
93+
}
94+
95+
return false, err
96+
}

cloud/linode/client/mocks/mock_client.go

+15
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cloud/linode/cloud.go

+44-16
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package linode
22

33
import (
4+
"context"
45
"fmt"
56
"io"
67
"net"
@@ -19,11 +20,12 @@ import (
1920

2021
const (
2122
// The name of this cloudprovider
22-
ProviderName = "linode"
23-
accessTokenEnv = "LINODE_API_TOKEN"
24-
regionEnv = "LINODE_REGION"
25-
ciliumLBType = "cilium-bgp"
26-
nodeBalancerLBType = "nodebalancer"
23+
ProviderName = "linode"
24+
accessTokenEnv = "LINODE_API_TOKEN"
25+
regionEnv = "LINODE_REGION"
26+
ciliumLBType = "cilium-bgp"
27+
nodeBalancerLBType = "nodebalancer"
28+
tokenHealthCheckPeriod = 5 * time.Minute
2729
)
2830

2931
var supportedLoadBalancerTypes = []string{ciliumLBType, nodeBalancerLBType}
@@ -32,9 +34,10 @@ var supportedLoadBalancerTypes = []string{ciliumLBType, nodeBalancerLBType}
3234
// We expect it to be initialized with flags external to this package, likely in
3335
// main.go
3436
var Options struct {
35-
KubeconfigFlag *pflag.Flag
36-
LinodeGoDebug bool
37-
EnableRouteController bool
37+
KubeconfigFlag *pflag.Flag
38+
LinodeGoDebug bool
39+
EnableRouteController bool
40+
EnableTokenHealthChecker bool
3841
// Deprecated: use VPCNames instead
3942
VPCName string
4043
VPCNames string
@@ -43,13 +46,15 @@ var Options struct {
4346
IpHolderSuffix string
4447
LinodeExternalNetwork *net.IPNet
4548
NodeBalancerTags []string
49+
GlobalStopChannel chan<- struct{}
4650
}
4751

4852
type linodeCloud struct {
49-
client client.Client
50-
instances cloudprovider.InstancesV2
51-
loadbalancers cloudprovider.LoadBalancer
52-
routes cloudprovider.Routes
53+
client client.Client
54+
instances cloudprovider.InstancesV2
55+
loadbalancers cloudprovider.LoadBalancer
56+
routes cloudprovider.Routes
57+
linodeTokenHealthChecker *healthChecker
5358
}
5459

5560
var instanceCache *instances
@@ -91,6 +96,24 @@ func newCloud() (cloudprovider.Interface, error) {
9196
linodeClient.SetDebug(true)
9297
}
9398

99+
var healthChecker *healthChecker
100+
101+
if Options.EnableTokenHealthChecker {
102+
authenticated, err := client.CheckClientAuthenticated(context.TODO(), linodeClient)
103+
if err != nil {
104+
return nil, fmt.Errorf("linode client authenticated connection error: %w", err)
105+
}
106+
107+
if !authenticated {
108+
return nil, fmt.Errorf("linode api token %q is invalid", accessTokenEnv)
109+
}
110+
111+
healthChecker, err = newHealthChecker(apiToken, timeout, tokenHealthCheckPeriod, Options.GlobalStopChannel)
112+
if err != nil {
113+
return nil, fmt.Errorf("unable to initialize healthchecker: %w", err)
114+
}
115+
}
116+
94117
if Options.VPCName != "" && Options.VPCNames != "" {
95118
return nil, fmt.Errorf("cannot have both vpc-name and vpc-names set")
96119
}
@@ -126,10 +149,11 @@ func newCloud() (cloudprovider.Interface, error) {
126149

127150
// create struct that satisfies cloudprovider.Interface
128151
lcloud := &linodeCloud{
129-
client: linodeClient,
130-
instances: instanceCache,
131-
loadbalancers: newLoadbalancers(linodeClient, region),
132-
routes: routes,
152+
client: linodeClient,
153+
instances: instanceCache,
154+
loadbalancers: newLoadbalancers(linodeClient, region),
155+
routes: routes,
156+
linodeTokenHealthChecker: healthChecker,
133157
}
134158
return lcloud, nil
135159
}
@@ -140,6 +164,10 @@ func (c *linodeCloud) Initialize(clientBuilder cloudprovider.ControllerClientBui
140164
serviceInformer := sharedInformer.Core().V1().Services()
141165
nodeInformer := sharedInformer.Core().V1().Nodes()
142166

167+
if c.linodeTokenHealthChecker != nil {
168+
go c.linodeTokenHealthChecker.Run(stopCh)
169+
}
170+
143171
serviceController := newServiceController(c.loadbalancers.(*loadbalancers), serviceInformer)
144172
go serviceController.Run(stopCh)
145173

cloud/linode/health_check.go

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package linode
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/linode/linode-cloud-controller-manager/cloud/linode/client"
8+
"k8s.io/apimachinery/pkg/util/wait"
9+
"k8s.io/klog/v2"
10+
)
11+
12+
type healthChecker struct {
13+
period time.Duration
14+
linodeClient client.Client
15+
stopCh chan<- struct{}
16+
}
17+
18+
func newHealthChecker(apiToken string, timeout time.Duration, period time.Duration, stopCh chan<- struct{}) (*healthChecker, error) {
19+
client, err := client.New(apiToken, timeout)
20+
if err != nil {
21+
return nil, err
22+
}
23+
24+
return &healthChecker{
25+
period: period,
26+
linodeClient: client,
27+
stopCh: stopCh,
28+
}, nil
29+
}
30+
31+
func (r *healthChecker) Run(stopCh <-chan struct{}) {
32+
ctx := wait.ContextForChannel(stopCh)
33+
wait.Until(r.worker(ctx), r.period, stopCh)
34+
}
35+
36+
func (r *healthChecker) worker(ctx context.Context) func() {
37+
return func() {
38+
r.do(ctx)
39+
}
40+
}
41+
42+
func (r *healthChecker) do(ctx context.Context) {
43+
if r.stopCh == nil {
44+
klog.Errorf("stop signal already fired. nothing to do")
45+
return
46+
}
47+
48+
authenticated, err := client.CheckClientAuthenticated(ctx, r.linodeClient)
49+
if err != nil {
50+
klog.Warningf("unable to determine linode client authentication status: %s", err.Error())
51+
return
52+
}
53+
54+
if !authenticated {
55+
klog.Error("detected invalid linode api token: stopping controllers")
56+
57+
close(r.stopCh)
58+
r.stopCh = nil
59+
return
60+
}
61+
62+
klog.Info("linode api token is healthy")
63+
}

cloud/linode/health_check_test.go

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package linode
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/golang/mock/gomock"
8+
"github.com/linode/linode-cloud-controller-manager/cloud/linode/client/mocks"
9+
"github.com/linode/linodego"
10+
)
11+
12+
func TestHealthCheck(t *testing.T) {
13+
testCases := []struct {
14+
name string
15+
f func(*testing.T, *mocks.MockClient)
16+
}{
17+
{
18+
name: "Test succeeding calls to linode api stop signal is not fired",
19+
f: testSucceedingCallsToLinodeAPIHappenStopSignalNotFired,
20+
},
21+
{
22+
name: "Test Unauthorized calls to linode api stop signal is fired",
23+
f: testFailingCallsToLinodeAPIHappenStopSignalFired,
24+
},
25+
{
26+
name: "Test failing calls to linode api stop signal is not fired",
27+
f: testErrorCallsToLinodeAPIHappenStopSignalNotFired,
28+
},
29+
}
30+
31+
for _, tc := range testCases {
32+
t.Run(tc.name, func(t *testing.T) {
33+
ctrl := gomock.NewController(t)
34+
defer ctrl.Finish()
35+
36+
client := mocks.NewMockClient(ctrl)
37+
tc.f(t, client)
38+
})
39+
}
40+
}
41+
42+
func testSucceedingCallsToLinodeAPIHappenStopSignalNotFired(t *testing.T, client *mocks.MockClient) {
43+
writableStopCh := make(chan struct{})
44+
readableStopCh := make(chan struct{})
45+
46+
client.EXPECT().GetProfile(gomock.Any()).Times(2).Return(&linodego.Profile{}, nil)
47+
48+
hc, err := newHealthChecker("validToken", 1*time.Second, 1*time.Second, writableStopCh)
49+
if err != nil {
50+
t.Fatalf("expected a nil error, got %v", err)
51+
}
52+
// inject mocked linodego.Client
53+
hc.linodeClient = client
54+
55+
defer close(readableStopCh)
56+
go hc.Run(readableStopCh)
57+
58+
// wait for two checks to happen
59+
time.Sleep(1500 * time.Millisecond)
60+
61+
select {
62+
case <-writableStopCh:
63+
t.Error("healthChecker sent stop signal")
64+
default:
65+
}
66+
}
67+
68+
func testFailingCallsToLinodeAPIHappenStopSignalFired(t *testing.T, client *mocks.MockClient) {
69+
writableStopCh := make(chan struct{})
70+
readableStopCh := make(chan struct{})
71+
72+
client.EXPECT().GetProfile(gomock.Any()).Times(1).Return(&linodego.Profile{}, nil)
73+
74+
hc, err := newHealthChecker("validToken", 1*time.Second, 1*time.Second, writableStopCh)
75+
if err != nil {
76+
t.Fatalf("expected a nil error, got %v", err)
77+
}
78+
// inject mocked linodego.Client
79+
hc.linodeClient = client
80+
81+
defer close(readableStopCh)
82+
go hc.Run(readableStopCh)
83+
84+
// wait for check to happen
85+
time.Sleep(500 * time.Millisecond)
86+
87+
select {
88+
case <-writableStopCh:
89+
t.Error("healthChecker sent stop signal")
90+
default:
91+
}
92+
93+
// invalidate token
94+
client.EXPECT().GetProfile(gomock.Any()).Times(1).Return(&linodego.Profile{}, &linodego.Error{Code: 401, Message: "Invalid Token"})
95+
96+
// wait for check to happen
97+
time.Sleep(1 * time.Second)
98+
99+
select {
100+
case <-writableStopCh:
101+
default:
102+
t.Error("healthChecker did not send stop signal")
103+
}
104+
}
105+
106+
func testErrorCallsToLinodeAPIHappenStopSignalNotFired(t *testing.T, client *mocks.MockClient) {
107+
writableStopCh := make(chan struct{})
108+
readableStopCh := make(chan struct{})
109+
110+
client.EXPECT().GetProfile(gomock.Any()).Times(1).Return(&linodego.Profile{}, nil)
111+
112+
hc, err := newHealthChecker("validToken", 1*time.Second, 1*time.Second, writableStopCh)
113+
if err != nil {
114+
t.Fatalf("expected a nil error, got %v", err)
115+
}
116+
// inject mocked linodego.Client
117+
hc.linodeClient = client
118+
119+
defer close(readableStopCh)
120+
go hc.Run(readableStopCh)
121+
122+
// wait for check to happen
123+
time.Sleep(500 * time.Millisecond)
124+
125+
select {
126+
case <-writableStopCh:
127+
t.Error("healthChecker sent stop signal")
128+
default:
129+
}
130+
131+
// simulate server error
132+
client.EXPECT().GetProfile(gomock.Any()).Times(1).Return(&linodego.Profile{}, &linodego.Error{Code: 500})
133+
134+
// wait for check to happen
135+
time.Sleep(1 * time.Second)
136+
137+
select {
138+
case <-writableStopCh:
139+
t.Error("healthChecker sent stop signal")
140+
default:
141+
}
142+
143+
client.EXPECT().GetProfile(gomock.Any()).Times(1).Return(&linodego.Profile{}, nil)
144+
145+
// wait for check to happen
146+
time.Sleep(1 * time.Second)
147+
148+
select {
149+
case <-writableStopCh:
150+
t.Error("healthChecker sent stop signal")
151+
default:
152+
}
153+
}

0 commit comments

Comments
 (0)