@@ -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+
176318func TestInstances_InstanceShutdown (t * testing.T ) {
177319 env := newTestEnv ()
178320 defer env .Teardown ()
0 commit comments