Skip to content

Commit 12329f5

Browse files
authored
🌱 Force Cache Refresh when Server was not found by name. (#84)
1 parent 0dfac3c commit 12329f5

8 files changed

Lines changed: 341 additions & 8 deletions

File tree

hcloud/cloud_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"github.com/syself/hetzner-cloud-controller-manager/internal/annotation"
3535
"github.com/syself/hetzner-cloud-controller-manager/internal/credentials"
3636
"github.com/syself/hetzner-cloud-controller-manager/internal/hcops"
37+
robotclient "github.com/syself/hetzner-cloud-controller-manager/internal/robot/client"
3738
hrobot "github.com/syself/hrobot-go"
3839
"github.com/syself/hrobot-go/models"
3940
corev1 "k8s.io/api/core/v1"
@@ -44,7 +45,7 @@ type testEnv struct {
4445
Server *httptest.Server
4546
Mux *http.ServeMux
4647
Client *hcloud.Client
47-
RobotClient hrobot.RobotClient
48+
RobotClient robotclient.Client
4849
}
4950

5051
func (env *testEnv) Teardown() {
@@ -70,7 +71,7 @@ func newTestEnv() testEnv {
7071
Server: server,
7172
Mux: mux,
7273
Client: client,
73-
RobotClient: robotClient,
74+
RobotClient: robotclient.New(robotClient),
7475
}
7576
}
7677

hcloud/instances_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
"github.com/hetznercloud/hcloud-go/v2/hcloud"
2828
"github.com/hetznercloud/hcloud-go/v2/hcloud/schema"
29+
"github.com/syself/hetzner-cloud-controller-manager/internal/robot/client/cache"
2930
"github.com/syself/hrobot-go/models"
3031
corev1 "k8s.io/api/core/v1"
3132
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -173,6 +174,147 @@ func TestInstances_InstanceExists(t *testing.T) {
173174
}
174175
}
175176

177+
func TestInstances_InstanceExistsRobotServerCreatedAfterCacheFill(t *testing.T) {
178+
env := newTestEnv()
179+
defer env.Teardown()
180+
181+
resetEnv := Setenv(t,
182+
"ROBOT_USER_NAME", "user",
183+
"ROBOT_PASSWORD", "pass",
184+
"CACHE_TIMEOUT", "1h",
185+
)
186+
defer resetEnv()
187+
188+
// servers backs the Robot list response and is mutated during the test.
189+
servers := make([]models.Server, 0, 2)
190+
servers = append(servers, models.Server{
191+
ServerIP: "123.123.123.123",
192+
ServerIPv6Net: "2a01:f48:111:4221::",
193+
ServerNumber: 321,
194+
Name: "bm-existing",
195+
})
196+
env.Mux.HandleFunc("/robot/server", func(w http.ResponseWriter, _ *http.Request) {
197+
responses := make([]models.ServerResponse, 0, len(servers))
198+
for _, server := range servers {
199+
responses = append(responses, models.ServerResponse{Server: server})
200+
}
201+
json.NewEncoder(w).Encode(responses)
202+
})
203+
204+
robotClient, err := cache.NewCachedRobotClient(t.TempDir(), env.Server.Client(), env.Server.URL+"/robot")
205+
if err != nil {
206+
t.Fatalf("Unexpected error creating cached robot client: %v", err)
207+
}
208+
209+
instances := newInstances(env.Client, robotClient, AddressFamilyIPv4, 0)
210+
211+
// Warm the cache while bm-new does not exist yet.
212+
exists, err := instances.InstanceExists(context.TODO(), &corev1.Node{
213+
ObjectMeta: metav1.ObjectMeta{Name: "bm-existing"},
214+
})
215+
if err != nil {
216+
t.Fatalf("Unexpected error warming cache: %v", err)
217+
}
218+
if !exists {
219+
t.Fatal("Expected bm-existing to exist")
220+
}
221+
222+
servers = append(servers, models.Server{
223+
ServerIP: "123.123.123.124",
224+
ServerIPv6Net: "2a01:f48:111:4222::",
225+
ServerNumber: 322,
226+
Name: "bm-new",
227+
})
228+
229+
exists, err = instances.InstanceExists(context.TODO(), &corev1.Node{
230+
ObjectMeta: metav1.ObjectMeta{Name: "bm-new"},
231+
})
232+
if err != nil {
233+
t.Fatalf("Unexpected error for bm-new: %v", err)
234+
}
235+
if !exists {
236+
t.Fatal("Expected bm-new to exist after it was created")
237+
}
238+
}
239+
240+
func TestInstances_InstanceExistsRobotServerRepeatedMissingNameSkipsSecondForceRefresh(t *testing.T) {
241+
// This test exercises the name-based Robot lookup path in getRobotServerByName:
242+
//
243+
// 1. ServerGetList() checks the cached Robot server list.
244+
// 2. If the name is missing there, ServerGetListForceRefresh(node.Name) does one uncached reload.
245+
// 3. A second lookup for the same still-missing name within CACHE_TIMEOUT must not trigger
246+
// another uncached reload.
247+
//
248+
// The behavior is important because CAPH can rename Robot servers during provisioning, so the
249+
// first miss should recover from a stale cache, but repeated misses for the same name should not
250+
// hammer the Robot API.
251+
env := newTestEnv()
252+
defer env.Teardown()
253+
254+
resetEnv := Setenv(t,
255+
"ROBOT_USER_NAME", "user",
256+
"ROBOT_PASSWORD", "pass",
257+
"CACHE_TIMEOUT", "1h",
258+
)
259+
defer resetEnv()
260+
261+
robotListHTTPCalls := 0
262+
env.Mux.HandleFunc("/robot/server", func(w http.ResponseWriter, _ *http.Request) {
263+
robotListHTTPCalls++
264+
json.NewEncoder(w).Encode([]models.ServerResponse{
265+
{
266+
Server: models.Server{
267+
ServerIP: "123.123.123.123",
268+
ServerIPv6Net: "2a01:f48:111:4221::",
269+
ServerNumber: 321,
270+
Name: "bm-existing",
271+
},
272+
},
273+
})
274+
})
275+
276+
robotClient, err := cache.NewCachedRobotClient(t.TempDir(), env.Server.Client(), env.Server.URL+"/robot")
277+
if err != nil {
278+
t.Fatalf("Unexpected error creating cached robot client: %v", err)
279+
}
280+
281+
instances := newInstances(env.Client, robotClient, AddressFamilyIPv4, 0)
282+
node := &corev1.Node{
283+
ObjectMeta: metav1.ObjectMeta{Name: "bm-missing"},
284+
}
285+
286+
// First lookup for bm-missing:
287+
// - ServerGetList() loads the current list from Robot. That is HTTP call 1.
288+
// - bm-missing is not present, so getRobotServerByName forces one reload. That is HTTP call 2.
289+
exists, err := instances.InstanceExists(context.TODO(), node)
290+
if err != nil {
291+
t.Fatalf("Unexpected error on first miss: %v", err)
292+
}
293+
if exists {
294+
t.Fatal("Expected bm-missing to be absent on first lookup")
295+
}
296+
if robotListHTTPCalls != 2 {
297+
t.Fatalf("Expected 2 Robot list calls after first miss, got %d", robotListHTTPCalls)
298+
}
299+
callsAfterFirstMiss := robotListHTTPCalls
300+
301+
// Second lookup for the same missing name within CACHE_TIMEOUT:
302+
// - ServerGetList() is served from cache, so there is no extra HTTP call.
303+
// - ServerGetListForceRefresh(node.Name) notices that bm-missing already triggered a forced
304+
// refresh in this cache window, so it reuses the cached list instead of issuing another HTTP
305+
// request.
306+
exists, err = instances.InstanceExists(context.TODO(), node)
307+
if err != nil {
308+
t.Fatalf("Unexpected error on second miss: %v", err)
309+
}
310+
if exists {
311+
t.Fatal("Expected bm-missing to be absent on second lookup")
312+
}
313+
if robotListHTTPCalls != callsAfterFirstMiss {
314+
t.Fatalf("Expected repeated miss to skip force refresh, got %d Robot list calls", robotListHTTPCalls)
315+
}
316+
}
317+
176318
func TestInstances_InstanceShutdown(t *testing.T) {
177319
env := newTestEnv()
178320
defer env.Teardown()

hcloud/util.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,29 @@ func getRobotServerByName(c robotclient.Client, node *corev1.Node) (server *mode
7373

7474
for i, s := range serverList {
7575
if s.Name == node.Name {
76-
server = &serverList[i]
76+
return &serverList[i], nil
7777
}
7878
}
7979

80-
return server, nil
80+
// CAPH changes the Robot server name during provisioning. This means the cache of the
81+
// server-name-to-server-ID mapping could be outdated. Force one uncached Robot list reload for
82+
// that name to handle the rename. The Robot client suppresses repeated forced refreshes for the
83+
// same missing name until the normal Robot list cache timeout has elapsed.
84+
serverList, err = c.ServerGetListForceRefresh(string(node.Name))
85+
if err != nil {
86+
hcops.HandleRateLimitExceededError(err, node)
87+
return nil, fmt.Errorf("%s: force refresh after cache miss: %w", op, err)
88+
}
89+
90+
for i, s := range serverList {
91+
if s.Name == node.Name {
92+
// Server was found after cache refresh
93+
return &serverList[i], nil
94+
}
95+
}
96+
97+
// No server found.
98+
return nil, nil
8199
}
82100

83101
func getRobotServerByID(c robotclient.Client, id int, node *corev1.Node) (s *models.Server, e error) {

internal/mocks/robot.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ func (m *RobotClient) ServerGetList() ([]models.Server, error) {
1414
return getRobotServers(args, 0), args.Error(1)
1515
}
1616

17+
func (m *RobotClient) ServerGetListForceRefresh(_ string) ([]models.Server, error) {
18+
return m.ServerGetList()
19+
}
20+
1721
func (m *RobotClient) SetCredentials(_, _ string) error {
1822
args := m.Called()
1923
return args.Error(3)

internal/robot/client/adapter.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package client
2+
3+
import (
4+
hrobot "github.com/syself/hrobot-go"
5+
"github.com/syself/hrobot-go/models"
6+
)
7+
8+
var _ Client = &adapter{}
9+
10+
type adapter struct {
11+
hrobot.RobotClient
12+
}
13+
14+
// New wraps a plain Robot client so it satisfies the local Client interface.
15+
func New(robotClient hrobot.RobotClient) Client {
16+
if robotClient == nil {
17+
return nil
18+
}
19+
return &adapter{RobotClient: robotClient}
20+
}
21+
22+
// ServerGetListForceRefresh falls back to the plain list call because the
23+
// uncached behavior only exists in the cache-backed client implementation.
24+
func (a *adapter) ServerGetListForceRefresh(_ string) ([]models.Server, error) {
25+
return a.ServerGetList()
26+
}

internal/robot/client/cache/client.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
robotUserNameENVVar = "ROBOT_USER_NAME"
1919
robotPasswordENVVar = "ROBOT_PASSWORD"
2020
cacheTimeoutENVVar = "CACHE_TIMEOUT"
21+
defaultCacheTimeout = 5 * time.Minute
2122
)
2223

2324
var _ robotclient.Client = &cacheRobotClient{}
@@ -27,10 +28,16 @@ type cacheRobotClient struct {
2728
timeout time.Duration
2829

2930
lastUpdate time.Time
31+
now func() time.Time
3032

3133
// cache
3234
l []models.Server
3335
m map[int]*models.Server
36+
37+
// forcedRefreshServerNames stores when a node name last triggered a forced Robot server list
38+
// refresh. While that timestamp is still within the cache timeout window, repeated lookups for
39+
// the same missing server name skip the extra uncached Robot API call.
40+
forcedRefreshServerNames map[string]time.Time
3441
}
3542

3643
// NewCachedRobotClient creates a new robot client with caching enabled.
@@ -51,7 +58,7 @@ func NewCachedRobotClient(rootDir string, httpClient *http.Client, baseURL strin
5158
}
5259

5360
if cacheTimeout == 0 {
54-
cacheTimeout = 5 * time.Minute
61+
cacheTimeout = defaultCacheTimeout
5562
}
5663

5764
credentialsDir := credentials.GetDirectory(rootDir)
@@ -85,6 +92,8 @@ func NewCachedRobotClient(rootDir string, httpClient *http.Client, baseURL strin
8592
handler := &cacheRobotClient{}
8693
handler.timeout = cacheTimeout
8794
handler.robotClient = c
95+
handler.now = time.Now
96+
handler.forcedRefreshServerNames = make(map[string]time.Time)
8897
return handler, nil
8998
}
9099

@@ -105,7 +114,7 @@ func (c *cacheRobotClient) ServerGet(id int) (*models.Server, error) {
105114
}
106115

107116
// set time of last update
108-
c.lastUpdate = time.Now()
117+
c.lastUpdate = c.currentTime()
109118
}
110119

111120
server, found := c.m[id]
@@ -134,30 +143,88 @@ func (c *cacheRobotClient) ServerGetList() ([]models.Server, error) {
134143
}
135144

136145
// set time of last update
137-
c.lastUpdate = time.Now()
146+
c.lastUpdate = c.currentTime()
138147
}
139148

140149
return c.l, nil
141150
}
142151

152+
// ServerGetListForceRefresh invalidates the current cache and reloads the list
153+
// from Robot unless nodeName already triggered a forced refresh within the
154+
// current timeout window.
155+
func (c *cacheRobotClient) ServerGetListForceRefresh(nodeName string) ([]models.Server, error) {
156+
if nodeName != "" && c.nodeHasAlreadyForcedRefresh(nodeName) {
157+
return c.ServerGetList()
158+
}
159+
160+
// setting cache of serverId to serverName mapping to nil, so that the next ServerGetList() will
161+
// call the robot API to get the new data.
162+
c.m = nil
163+
164+
list, err := c.ServerGetList()
165+
if err != nil {
166+
return nil, err
167+
}
168+
169+
if nodeName != "" {
170+
c.forcedRefreshServerNames[nodeName] = c.currentTime()
171+
}
172+
173+
return list, nil
174+
}
175+
176+
// nodeHasAlreadyForcedRefresh reports whether nodeName already triggered a
177+
// forced refresh within the current cache timeout window.
178+
func (c *cacheRobotClient) nodeHasAlreadyForcedRefresh(nodeName string) bool {
179+
if c.forcedRefreshServerNames == nil {
180+
return false
181+
}
182+
183+
forcedAt, found := c.forcedRefreshServerNames[nodeName]
184+
if !found {
185+
return false
186+
}
187+
188+
if c.currentTime().After(forcedAt.Add(c.forceRefreshTimeout())) {
189+
delete(c.forcedRefreshServerNames, nodeName)
190+
return false
191+
}
192+
193+
return true
194+
}
143195
func (c *cacheRobotClient) shouldSync() bool {
144196
// map is nil means we have no cached value yet
145197
if c.m == nil {
146198
c.m = make(map[int]*models.Server)
147199
return true
148200
}
149-
if time.Now().After(c.lastUpdate.Add(c.timeout)) {
201+
if c.currentTime().After(c.lastUpdate.Add(c.timeout)) {
150202
return true
151203
}
152204
return false
153205
}
154206

207+
func (c *cacheRobotClient) currentTime() time.Time {
208+
if c.now == nil {
209+
return time.Now()
210+
}
211+
return c.now()
212+
}
213+
214+
func (c *cacheRobotClient) forceRefreshTimeout() time.Duration {
215+
if c.timeout == 0 {
216+
return defaultCacheTimeout
217+
}
218+
return c.timeout
219+
}
220+
155221
func (c *cacheRobotClient) SetCredentials(username, password string) error {
156222
err := c.robotClient.SetCredentials(username, password)
157223
if err != nil {
158224
return err
159225
}
160226
// The credentials have been updated, so we need to invalidate the cache.
161227
c.m = nil
228+
c.forcedRefreshServerNames = nil
162229
return nil
163230
}

0 commit comments

Comments
 (0)