l,"usage":{"input_tokens":3,"cache_creation_input_tokens":4842,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4842},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CXwASCtRyW6KLHc3r1itn","type":"assistant","uuid":"e422e1f1-4210-4189-a2c6-3750d8e0cf59","timestamp":"2026-02-08T19:35:34.175Z"} {"parentUuid":"e422e1f1-4210-4189-a2c6-3750d8e0cf59","isSidechain":true,"userType":"external","cwd":"/home/josie/development/kubernetes/karpenter-provider-ibm-cloud","sessionId":"8bfa3daa-29ad-4a1c-a335-851615a77550","version":"2.1.37","gitBranch":"fix/pre-commit-hooks","agentId":"ab458fe","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Bash","command":"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.py"},"parentToolUseID":"toolu_013TwsiWrbgGP4dtR3P1XYkn","toolUseID":"toolu_013TwsiWrbgGP4dtR3P1XYkn","timestamp":"2026-02-08T19:35:35.392Z","uuid":"1e86869b-a8b9-41e9-959c-c2a8b13cb5f7"} {"parentUuid":"d79e7fd5-0bce-445f-8451-8d4d31ea65d2","isSidechain":true,"userType":"external","cwd":"/home/josie/development/kubernetes/karpenter-provider-ibm-cloud","sessionId":"8bfa3daa-29ad-4a1c-a335-851615a77550","version":"2.1.37","gitBranch":"fix/pre-commit-hooks","agentId":"ab458fe","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013TwsiWrbgGP4dtR3P1XYkn","type":"tool_result","content":"title:\tfeat: add instance caching\nstate:\tOPEN\nauthor:\tmeomnzak\nlabels:\tcncf-cla: yes, size/L\nassignees:\t\nreviewers:\tpfeifferj (Requested)\nprojects:\t\nmilestone:\t\nnumber:\t505\nurl:\thttps://github.com/kubernetes-sigs/karpenter-provider-ibm-cloud/pull/505\nadditions:\t387\ndeletions:\t21\nauto-merge:\tdisabled\n--\n## Description\r\n\r\nadd instance caching\r\n\r\n## Type of change\r\n\r\n\r\n\r\n- [ ] Bug fix (non-breaking change which fixes an issue)\r\n- [ ] New feature (non-breaking change which adds functionality)\r\n- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)\r\n- [ ] Documentation update\r\n- [ ] Helm chart change\r\n- [ ] Other (please describe)\r\n\r\n## Testing\r\n\r\n\r\n\r\n- [ ] Unit tests added/updated\r\n- [ ] Helm chart tests pass (`helm lint` and template validation)\r\n- [ ] Tested changes manually\r\n- [ ] Added examples for new features\r\n\r\n## Checklist\r\n\r\n\r\n\r\n- [ ] My code follows the style guidelines of this project\r\n- [ ] I have performed a self-review of my code\r\n- [ ] I have commented my code, particularly in hard-to-understand areas\r\n- [ ] I have made corresponding changes to the documentation\r\n- [ ] My changes generate no new warnings\r\n- [ ] I have updated the relevant CRDs if needed\r\n- [ ] I have updated the Helm chart version if needed\r\n- [ ] I have added tests that prove my fix is effective or that my feature works\r\n\r\n## Additional context\r\n\r\ncloses #497","is_error":false}]},"uuid":"ac6fba7b-a500-4a26-87b5-c4391da050ee","timestamp":"2026-02-08T19:35:35.390Z","sourceToolAssistantUUID":"d79e7fd5-0bce-445f-8451-8d4d31ea65d2"} {"parentUuid":"ac6fba7b-a500-4a26-87b5-c4391da050ee","isSidechain":true,"userType":"external","cwd":"/home/josie/development/kubernetes/karpenter-provider-ibm-cloud","sessionId":"8bfa3daa-29ad-4a1c-a335-851615a77550","version":"2.1.37","gitBranch":"fix/pre-commit-hooks","agentId":"ab458fe","type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"PreToolUse:Bash","command":"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py"},"toolUseID":"toolu_01VyaHXEmNLdUyXhZ2F4349U","parentToolUseID":"toolu_01VyaHXEmNLdUyXhZ2F4349U","uuid":"2754ac99-2aff-4709-ae93-cb23cf7ca998","timestamp":"2026-02-08T19:35:35.427Z"} {"parentUuid":"2754ac99-2aff-4709-ae93-cb23cf7ca998","isSidechain":true,"userType":"external","cwd":"/home/josie/development/kubernetes/karpenter-provider-ibm-cloud","sessionId":"8bfa3daa-29ad-4a1c-a335-851615a77550","version":"2.1.37","gitBranch":"fix/pre-commit-hooks","agentId":"ab458fe","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Bash","command":"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.py"},"parentToolUseID":"toolu_01VyaHXEmNLdUyXhZ2F4349U","toolUseID":"toolu_01VyaHXEmNLdUyXhZ2F4349U","timestamp":"2026-02-08T19:35:35.942Z","uuid":"d2c4abe2-1765-405a-b4bf-4d06551b2a4a"} {"parentUuid":"e422e1f1-4210-4189-a2c6-3750d8e0cf59","isSidechain":true,"userType":"external","cwd":"/home/josie/development/kubernetes/karpenter-provider-ibm-cloud","sessionId":"8bfa3daa-29ad-4a1c-a335-851615a77550","version":"2.1.37","gitBranch":"fix/pre-commit-hooks","agentId":"ab458fe","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VyaHXEmNLdUyXhZ2F4349U","type":"tool_result","content":"diff --git a/pkg/cloudprovider/ibm/client.go b/pkg/cloudprovider/ibm/client.go\nindex 17a46d5..cb4eb50 100644\n--- a/pkg/cloudprovider/ibm/client.go\n+++ b/pkg/cloudprovider/ibm/client.go\n@@ -19,6 +19,7 @@ import (\n \t\"context\"\n \t\"fmt\"\n \t\"os\"\n+\t\"strings\"\n \t\"sync\"\n \t\"time\"\n \n@@ -258,3 +259,21 @@ func containsAnyString(s string, substrings []string) bool {\n \t}\n \treturn false\n }\n+\n+// ExtractRegionFromZone extracts the region from an IBM Cloud zone name\n+// Examples: \"br-sao-1\" -> \"br-sao\", \"eu-de-2\" -> \"eu-de\", \"us-south-3\" -> \"us-south\"\n+func ExtractRegionFromZone(zone string) string {\n+\tif zone == \"\" {\n+\t\treturn \"\"\n+\t}\n+\n+\t// IBM Cloud zone format is typically \"{region}-{zone-number}\"\n+\t// Find the last hyphen and take everything before it\n+\tlastHyphen := strings.LastIndex(zone, \"-\")\n+\tif lastHyphen == -1 {\n+\t\t// If no hyphen found, return the zone as-is (shouldn't happen in normal cases)\n+\t\treturn zone\n+\t}\n+\n+\treturn zone[:lastHyphen]\n+}\ndiff --git a/pkg/controllers/nodeclass/status/controller.go b/pkg/controllers/nodeclass/status/controller.go\nindex b542421..1baedf0 100644\n--- a/pkg/controllers/nodeclass/status/controller.go\n+++ b/pkg/controllers/nodeclass/status/controller.go\n@@ -570,7 +570,7 @@ func (c *Controller) validateSubnet(ctx context.Context, subnetID, vpcID, expect\n \t}\n \n \t// Extract region from subnet zone (e.g., \"br-sao-1\" -> \"br-sao\", \"eu-de-2\" -> \"eu-de\")\n-\tsubnetRegion := extractRegionFromZone(subnetInfo.Zone)\n+\tsubnetRegion := ibm.ExtractRegionFromZone(subnetInfo.Zone)\n \tif subnetRegion != expectedRegion {\n \t\treturn fmt.Errorf(\"subnet %s is in region %s but NodeClass expects region %s (subnet zone: %s). Cross-region subnet references are not supported\",\n \t\t\tsubnetID, subnetRegion, expectedRegion, subnetInfo.Zone)\n@@ -876,24 +876,6 @@ func (c *Controller) patchNodeClassStatus(ctx context.Context, nc *v1alpha1.IBMN\n \treturn c.kubeClient.Status().Patch(ctx, nc, client.MergeFromWithOptions(stored, client.MergeFromWithOptimisticLock{}))\n }\n \n-// extractRegionFromZone extracts the region from an IBM Cloud zone name\n-// Examples: \"br-sao-1\" -> \"br-sao\", \"eu-de-2\" -> \"eu-de\", \"us-south-3\" -> \"us-south\"\n-func extractRegionFromZone(zone string) string {\n-\tif zone == \"\" {\n-\t\treturn \"\"\n-\t}\n-\n-\t// IBM Cloud zone format is typically \"{region}-{zone-number}\"\n-\t// Find the last hyphen and take everything before it\n-\tlastHyphen := strings.LastIndex(zone, \"-\")\n-\tif lastHyphen == -1 {\n-\t\t// If no hyphen found, return the zone as-is (shouldn't happen in normal cases)\n-\t\treturn zone\n-\t}\n-\n-\treturn zone[:lastHyphen]\n-}\n-\n // Register registers the controller with the manager\n func (c *Controller) Register(_ context.Context, m manager.Manager) error {\n \treturn controllerruntime.NewControllerManagedBy(m).\ndiff --git a/pkg/providers/vpc/instance/provider.go b/pkg/providers/vpc/instance/provider.go\nindex da6367d..b6a6a0f 100644\n--- a/pkg/providers/vpc/instance/provider.go\n+++ b/pkg/providers/vpc/instance/provider.go\n@@ -27,6 +27,7 @@ import (\n \t\"github.com/IBM/go-sdk-core/v5/core\"\n \t\"github.com/IBM/vpc-go-sdk/vpcv1\"\n \t\"github.com/go-logr/logr\"\n+\t\"github.com/kubernetes-sigs/karpenter-provider-ibm-cloud/pkg/cache\"\n \tcorev1 \"k8s.io/api/core/v1\"\n \tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n \t\"k8s.io/apimachinery/pkg/types\"\n@@ -64,6 +65,7 @@ type VPCInstanceProvider struct {\n \tsubnetProvider subnet.Provider\n \tvpcClientManager *vpcclient.Manager\n \tresourceManagerService *resourcemanagerv2.ResourceManagerV2\n+\tinstanceCache *cache.Cache\n }\n \n // Option configures the VPCInstanceProvider\n@@ -106,6 +108,17 @@ func WithVPCClientManager(manager *vpcclient.Manager) Option {\n \t}\n }\n \n+// WithInstanceCache sets a custom instance cache (for testing)\n+func WithInstanceCache(c *cache.Cache) Option {\n+\treturn func(p *VPCInstanceProvider) error {\n+\t\tif c == nil {\n+\t\t\treturn fmt.Errorf(\"instance cache cannot be nil when provided\")\n+\t\t}\n+\t\tp.instanceCache = c\n+\t\treturn nil\n+\t}\n+}\n+\n // NewVPCInstanceProvider creates a new VPC instance provider with optional configuration\n func NewVPCInstanceProvider(client *ibm.Client, kubeClient client.Client, opts ...Option) (commonTypes.VPCInstanceProvider, error) {\n \tif client == nil {\n@@ -124,6 +137,7 @@ func NewVPCInstanceProvider(client *ibm.Client, kubeClient client.Client, opts .\n \t\tsubnetProvider: subnet.NewProvider(client),\n \t\tvpcClientManager: vpcclient.NewManager(client, constants.DefaultVPCClientCacheTTL),\n \t\tresourceManagerService: nil, // Will be initialized after applying options\n+\t\tinstanceCache: cache.New(constants.DefaultVPCClientCacheTTL),\n \t}\n \n \t// Apply options\n@@ -995,6 +1009,7 @@ func (p *VPCInstanceProvider) Delete(ctx context.Context, node *corev1.Node) err\n \t\treturn vpcclient.HandleVPCError(err, logger, \"deleting VPC instance\", \"instance_id\", instanceID)\n \t}\n \n+\tp.instanceCache.Delete(instanceID)\n \t// Check if the instance actually exists to confirm deletion status\n \t// This is critical for proper Karpenter finalizer management\n \t_, getErr := vpcClient.GetInstance(ctx, instanceID)\n@@ -1026,6 +1041,14 @@ func (p *VPCInstanceProvider) Get(ctx context.Context, providerID string) (*core\n \t\treturn nil, fmt.Errorf(\"could not extract instance ID from provider ID: %s\", providerID)\n \t}\n \n+\tif cached, exists := p.instanceCache.Get(instanceID); exists {\n+\t\tif node, ok := cached.(*corev1.Node); ok {\n+\t\t\treturn node, nil\n+\t\t}\n+\t\t// Type mismatch - delete invalid cache entry and continue to fetch\n+\t\tp.instanceCache.Delete(instanceID)\n+\t}\n+\n \tvpcClient, err := p.vpcClientManager.GetVPCClient(ctx)\n \tif err != nil {\n \t\treturn nil, err\n@@ -1049,6 +1072,10 @@ func (p *VPCInstanceProvider) Get(ctx context.Context, providerID string) (*core\n \t\t},\n \t}\n \n+\tif instance.Status == nil || *instance.Status != vpcv1.InstanceStatusDeletingConst {\n+\t\tp.instanceCache.Set(instanceID, node)\n+\t}\n+\n \treturn node, nil\n }\n \n@@ -1066,15 +1093,17 @@ func (p *VPCInstanceProvider) List(ctx context.Context) ([]*corev1.Node, error)\n \n \tvar nodes []*corev1.Node\n \tfor _, instance := range instances {\n-\t\tif instance.ID != nil && instance.Name != nil {\n+\t\tif instance.ID != nil && instance.Name != nil && instance.Zone != nil && instance.Zone.Name != nil {\n+\t\t\tregion := ibm.ExtractRegionFromZone(*instance.Zone.Name)\n \t\t\tnode := &corev1.Node{\n \t\t\t\tObjectMeta: metav1.ObjectMeta{\n \t\t\t\t\tName: *instance.Name,\n \t\t\t\t},\n \t\t\t\tSpec: corev1.NodeSpec{\n-\t\t\t\t\tProviderID: fmt.Sprintf(\"ibm:///%s/%s\", \"region\", *instance.ID),\n+\t\t\t\t\tProviderID: fmt.Sprintf(\"ibm:///%s/%s\", region, *instance.ID),\n \t\t\t\t},\n \t\t\t}\n+\t\t\tp.instanceCache.Set(*instance.ID, node)\n \t\t\tnodes = append(nodes, node)\n \t\t}\n \t}\ndiff --git a/pkg/providers/vpc/instance/provider_test.go b/pkg/providers/vpc/instance/provider_test.go\nindex 4f69aca..8e717bd 100644\n--- a/pkg/providers/vpc/instance/provider_test.go\n+++ b/pkg/providers/vpc/instance/provider_test.go\n@@ -21,11 +21,15 @@ import (\n \t\"fmt\"\n \t\"os\"\n \t\"testing\"\n+\t\"time\"\n \n \t\"github.com/IBM/go-sdk-core/v5/core\"\n \t\"github.com/IBM/vpc-go-sdk/vpcv1\"\n+\t\"github.com/kubernetes-sigs/karpenter-provider-ibm-cloud/pkg/cache\"\n+\t\"github.com/kubernetes-sigs/karpenter-provider-ibm-cloud/pkg/utils/vpcclient\"\n \t\"github.com/stretchr/testify/assert\"\n \t\"go.uber.org/mock/gomock\"\n+\tcorev1 \"k8s.io/api/core/v1\"\n \tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n \tkarpv1 \"sigs.k8s.io/karpenter/pkg/apis/v1\"\n \n@@ -2026,3 +2030,325 @@ func TestAddKarpenterTags(t *testing.T) {\n \t\t})\n \t}\n }\n+\n+func TestProviderGet_CacheHit(t *testing.T) {\n+\tctrl := gomock.NewController(t)\n+\tdefer ctrl.Finish()\n+\n+\t_ = os.Setenv(\"IBMCLOUD_API_KEY\", \"test-api-key\")\n+\tdefer func() { _ = os.Unsetenv(\"IBMCLOUD_API_KEY\") }()\n+\n+\tctx := context.Background()\n+\tmockVPC := mock_ibm.NewMockvpcClientInterface(ctrl)\n+\n+\ttestInstance := getTestVPCInstance()\n+\ttestResponse := &core.DetailedResponse{StatusCode: 200}\n+\n+\t// API called only ONCE - second Get hits cache\n+\tmockVPC.EXPECT().\n+\t\tGetInstanceWithContext(gomock.Any(), gomock.Any()).\n+\t\tReturn(testInstance, testResponse, nil).\n+\t\tTimes(1)\n+\n+\tvpcClient := ibm.NewVPCClientWithMock(mockVPC)\n+\tmanager := vpcclient.NewManagerWithMockClient(vpcClient)\n+\tinstanceCache := cache.New(5 * time.Minute)\n+\tdefer instanceCache.Stop()\n+\n+\tprovider, err := NewVPCInstanceProvider(\n+\t\t&ibm.Client{},\n+\t\t&mockKubeClient{},\n+\t\tWithVPCClientManager(manager),\n+\t\tWithInstanceCache(instanceCache),\n+\t)\n+\tassert.NoError(t, err)\n+\n+\tproviderID := \"ibm:///us-south/test-instance-id\"\n+\n+\t// First Get - calls API, populates cache\n+\tnode1, err := provider.Get(ctx, providerID)\n+\tassert.NoError(t, err)\n+\tassert.Equal(t, \"test-nodeclaim\", node1.Name)\n+\n+\t// Second Get - cache hit, no API call\n+\tnode2, err := provider.Get(ctx, providerID)\n+\tassert.NoError(t, err)\n+\tassert.Equal(t, \"test-nodeclaim\", node2.Name)\n+}\n+\n+func TestProviderDelete_InvalidatesCache(t *testing.T) {\n+\tctrl := gomock.NewController(t)\n+\tdefer ctrl.Finish()\n+\n+\t_ = os.Setenv(\"IBMCLOUD_API_KEY\", \"test-api-key\")\n+\tdefer func() { _ = os.Unsetenv(\"IBMCLOUD_API_KEY\") }()\n+\n+\tctx := context.Background()\n+\tmockVPC := mock_ibm.NewMockvpcClientInterface(ctrl)\n+\tdeleteResponse := &core.DetailedResponse{StatusCode: 204}\n+\n+\t// Delete call\n+\tmockVPC.EXPECT().\n+\t\tDeleteInstanceWithContext(gomock.Any(), gomock.Any()).\n+\t\tReturn(deleteResponse, nil).\n+\t\tTimes(1)\n+\n+\t// Delete's internal verification call\n+\tmockVPC.EXPECT().\n+\t\tGetInstanceWithContext(gomock.Any(), gomock.Any()).\n+\t\tReturn(nil, nil, fmt.Errorf(\"instance not found: 404\")).\n+\t\tTimes(1)\n+\n+\tvpcClient := ibm.NewVPCClientWithMock(mockVPC)\n+\tmanager := vpcclient.NewManagerWithMockClient(vpcClient)\n+\tinstanceCache := cache.New(5 * time.Minute)\n+\tdefer instanceCache.Stop()\n+\n+\t// Pre-populate cache\n+\tinstanceID := \"test-instance-id\"\n+\tcachedNode := &corev1.Node{\n+\t\tObjectMeta: metav1.ObjectMeta{Name: \"cached-node\"},\n+\t}\n+\tinstanceCache.Set(instanceID, cachedNode)\n+\n+\tprovider, err := NewVPCInstanceProvider(\n+\t\t&ibm.Client{},\n+\t\t&mockKubeClient{},\n+\t\tWithVPCClientManager(manager),\n+\t\tWithInstanceCache(instanceCache),\n+\t)\n+\tassert.NoError(t, err)\n+\n+\t// Verify cache has entry before delete\n+\t_, exists := instanceCache.Get(instanceID)\n+\tassert.True(t, exists, \"cache should have entry before delete\")\n+\n+\tnode := &corev1.Node{\n+\t\tObjectMeta: metav1.ObjectMeta{\n+\t\t\tName: \"test-node\",\n+\t\t\tLabels: map[string]string{\n+\t\t\t\t\"topology.kubernetes.io/region\": \"us-south\",\n+\t\t\t\t\"node.kubernetes.io/instance-type\": \"bx2-4x16\",\n+\t\t\t},\n+\t\t},\n+\t\tSpec: corev1.NodeSpec{\n+\t\t\tProviderID: \"ibm:///us-south/\" + instanceID,\n+\t\t},\n+\t}\n+\t_ = provider.Delete(ctx, node)\n+\n+\t// Verify cache entry is gone\n+\t_, exists = instanceCache.Get(instanceID)\n+\tassert.False(t, exists, \"cache should be empty after delete\")\n+}\n+\n+func TestProviderList_PopulatesCache(t *testing.T) {\n+\tctrl := gomock.NewController(t)\n+\tdefer ctrl.Finish()\n+\n+\t_ = os.Setenv(\"IBMCLOUD_API_KEY\", \"test-api-key\")\n+\tdefer func() { _ = os.Unsetenv(\"IBMCLOUD_API_KEY\") }()\n+\n+\tctx := context.Background()\n+\tmockVPC := mock_ibm.NewMockvpcClientInterface(ctrl)\n+\n+\ttestInstance := getTestVPCInstance()\n+\ttestCollection := &vpcv1.InstanceCollection{\n+\t\tInstances: []vpcv1.Instance{*testInstance},\n+\t}\n+\ttestResponse := &core.DetailedResponse{StatusCode: 200}\n+\n+\tmockVPC.EXPECT().\n+\t\tListInstancesWithContext(gomock.Any(), gomock.Any()).\n+\t\tReturn(testCollection, testResponse, nil).\n+\t\tTimes(1)\n+\n+\tvpcClient := ibm.NewVPCClientWithMock(mockVPC)\n+\tmanager := vpcclient.NewManagerWithMockClient(vpcClient)\n+\tinstanceCache := cache.New(5 * time.Minute)\n+\tdefer instanceCache.Stop()\n+\n+\tprovider, err := NewVPCInstanceProvider(\n+\t\t&ibm.Client{},\n+\t\t&mockKubeClient{},\n+\t\tWithVPCClientManager(manager),\n+\t\tWithInstanceCache(instanceCache),\n+\t)\n+\tassert.NoError(t, err)\n+\n+\t// Cache empty before List\n+\t_, exists := instanceCache.Get(\"test-instance-id\")\n+\tassert.False(t, exists, \"cache should be empty before list\")\n+\n+\t// List - populates cache\n+\tnodes, err := provider.List(ctx)\n+\tassert.NoError(t, err)\n+\tassert.Len(t, nodes, 1)\n+\n+\t// Cache now has the entry\n+\tcached, exists := instanceCache.Get(\"test-instance-id\")\n+\tassert.True(t, exists, \"cache should have entry after list\")\n+\tassert.Equal(t, \"test-nodeclaim\", cached.(*corev1.Node).Name)\n+}\n+\n+func TestProviderGet_DeletingInstanceNotCached(t *testing.T) {\n+\tctrl := gomock.NewController(t)\n+\tdefer ctrl.Finish()\n+\n+\t_ = os.Setenv(\"IBMCLOUD_API_KEY\", \"test-api-key\")\n+\tdefer func() { _ = os.Unsetenv(\"IBMCLOUD_API_KEY\") }()\n+\n+\tctx := context.Background()\n+\tmockVPC := mock_ibm.NewMockvpcClientInterface(ctrl)\n+\n+\t// Create instance with \"deleting\" status\n+\tinstanceID := \"test-instance-id\"\n+\tinstanceName := \"test-nodeclaim\"\n+\tdeletingStatus := vpcv1.InstanceStatusDeletingConst\n+\tzoneName := \"us-south-1\"\n+\n+\tdeletingInstance := &vpcv1.Instance{\n+\t\tID: &instanceID,\n+\t\tName: &instanceName,\n+\t\tStatus: &deletingStatus,\n+\t\tZone: &vpcv1.ZoneReference{\n+\t\t\tName: &zoneName,\n+\t\t},\n+\t}\n+\ttestResponse := &core.DetailedResponse{StatusCode: 200}\n+\n+\tmockVPC.EXPECT().\n+\t\tGetInstanceWithContext(gomock.Any(), gomock.Any()).\n+\t\tReturn(deletingInstance, testResponse, nil).\n+\t\tTimes(1)\n+\n+\tvpcClient := ibm.NewVPCClientWithMock(mockVPC)\n+\tmanager := vpcclient.NewManagerWithMockClient(vpcClient)\n+\tinstanceCache := cache.New(5 * time.Minute)\n+\tdefer instanceCache.Stop()\n+\n+\tprovider, err := NewVPCInstanceProvider(\n+\t\t&ibm.Client{},\n+\t\t&mockKubeClient{},\n+\t\tWithVPCClientManager(manager),\n+\t\tWithInstanceCache(instanceCache),\n+\t)\n+\tassert.NoError(t, err)\n+\n+\tproviderID := \"ibm:///us-south/test-instance-id\"\n+\n+\t// Get returns the node (behavior preserved)\n+\tnode, err := provider.Get(ctx, providerID)\n+\tassert.NoError(t, err)\n+\tassert.Equal(t, \"test-nodeclaim\", node.Name)\n+\n+\t// But cache should NOT have the entry (deleting instances not cached)\n+\t_, exists := instanceCache.Get(instanceID)\n+\tassert.False(t, exists, \"deleting instance should not be cached\")\n+}\n+\n+func TestProviderGet_InvalidCacheEntry_NoPanic(t *testing.T) {\n+\tctrl := gomock.NewController(t)\n+\tdefer ctrl.Finish()\n+\n+\t_ = os.Setenv(\"IBMCLOUD_API_KEY\", \"test-api-key\")\n+\tdefer func() { _ = os.Unsetenv(\"IBMCLOUD_API_KEY\") }()\n+\n+\tctx := context.Background()\n+\tmockVPC := mock_ibm.NewMockvpcClientInterface(ctrl)\n+\n+\ttestInstance := getTestVPCInstance()\n+\ttestResponse := &core.DetailedResponse{StatusCode: 200}\n+\n+\t// API should be called because invalid cache entry is deleted\n+\tmockVPC.EXPECT().\n+\t\tGetInstanceWithContext(gomock.Any(), gomock.Any()).\n+\t\tReturn(testInstance, testResponse, nil).\n+\t\tTimes(1)\n+\n+\tvpcClient := ibm.NewVPCClientWithMock(mockVPC)\n+\tmanager := vpcclient.NewManagerWithMockClient(vpcClient)\n+\tinstanceCache := cache.New(5 * time.Minute)\n+\tdefer instanceCache.Stop()\n+\n+\t// Pre-populate cache with WRONG type (string instead of *corev1.Node)\n+\tinstanceID := \"test-instance-id\"\n+\tinstanceCache.Set(instanceID, \"this is not a node\")\n+\n+\tprovider, err := NewVPCInstanceProvider(\n+\t\t&ibm.Client{},\n+\t\t&mockKubeClient{},\n+\t\tWithVPCClientManager(manager),\n+\t\tWithInstanceCache(instanceCache),\n+\t)\n+\tassert.NoError(t, err)\n+\n+\tproviderID := \"ibm:///us-south/test-instance-id\"\n+\n+\t// Should not panic, should fetch from API and return valid node\n+\tnode, err := provider.Get(ctx, providerID)\n+\tassert.NoError(t, err)\n+\tassert.Equal(t, \"test-nodeclaim\", node.Name)\n+\n+\t// Cache should now have the correct entry\n+\tcached, exists := instanceCache.Get(instanceID)\n+\tassert.True(t, exists, \"cache should have entry after fetch\")\n+\t_, ok := cached.(*corev1.Node)\n+\tassert.True(t, ok, \"cached entry should be *corev1.Node\")\n+}\n+\n+func TestProviderList_CorrectRegionFromZone(t *testing.T) {\n+\tctrl := gomock.NewController(t)\n+\tdefer ctrl.Finish()\n+\n+\t_ = os.Setenv(\"IBMCLOUD_API_KEY\", \"test-api-key\")\n+\tdefer func() { _ = os.Unsetenv(\"IBMCLOUD_API_KEY\") }()\n+\n+\tctx := context.Background()\n+\tmockVPC := mock_ibm.NewMockvpcClientInterface(ctrl)\n+\n+\t// Create instance with zone \"eu-de-2\" - region should be \"eu-de\"\n+\tinstanceID := \"test-instance-id\"\n+\tinstanceName := \"test-nodeclaim\"\n+\tzoneName := \"eu-de-2\"\n+\n+\ttestInstance := &vpcv1.Instance{\n+\t\tID: &instanceID,\n+\t\tName: &instanceName,\n+\t\tZone: &vpcv1.ZoneReference{\n+\t\t\tName: &zoneName,\n+\t\t},\n+\t}\n+\ttestCollection := &vpcv1.InstanceCollection{\n+\t\tInstances: []vpcv1.Instance{*testInstance},\n+\t}\n+\ttestResponse := &core.DetailedResponse{StatusCode: 200}\n+\n+\tmockVPC.EXPECT().\n+\t\tListInstancesWithContext(gomock.Any(), gomock.Any()).\n+\t\tReturn(testCollection, testResponse, nil).\n+\t\tTimes(1)\n+\n+\tvpcClient := ibm.NewVPCClientWithMock(mockVPC)\n+\tmanager := vpcclient.NewManagerWithMockClient(vpcClient)\n+\tinstanceCache := cache.New(5 * time.Minute)\n+\tdefer instanceCache.Stop()\n+\n+\tprovider, err := NewVPCInstanceProvider(\n+\t\t&ibm.Client{},\n+\t\t&mockKubeClient{},\n+\t\tWithVPCClientManager(manager),\n+\t\tWithInstanceCache(instanceCache),\n+\t)\n+\tassert.NoError(t, err)\n+\n+\tnodes, err := provider.List(ctx)\n+\tassert.NoError(t, err)\n+\tassert.Len(t, nodes, 1)\n+\n+\t// Verify ProviderID has correct region extracted from zone\n+\t// Zone \"eu-de-2\" should yield region \"eu-de\"\n+\texpectedProviderID := \"ibm:///eu-de/test-instance-id\"\n+\tassert.Equal(t, expectedProviderID, nodes[0].Spec.ProviderID,\n+\t\t\"ProviderID should have region 'eu-de' extracted from zone 'eu-de-2', not hardcoded 'region'\")\n+}\ndiff --git a/pkg/utils/vpcclient/manager.go b/pkg/utils/vpcclient/manager.go\nindex 938574a..d9f7afc 100644\n--- a/pkg/utils/vpcclient/manager.go\n+++ b/pkg/utils/vpcclient/manager.go\n@@ -145,3 +145,13 @@ func HandleVPCError(err error, logger logr.Logger, operation string, extraFields\n \n \treturn fmt.Errorf(\"%s: %w\", operation, err)\n }\n+\n+// NewManagerWithMockClient creates a Manager with a pre-set VPC client (for testing)\n+func NewManagerWithMockClient(vpcClient *ibm.VPCClient) *Manager {\n+\treturn &Manager{\n+\t\tclient: nil,\n+\t\tcachedClient: vpcClient,\n+\t\tcacheTime: time.Now(),\n+\t\tcacheTTL: time.Hour,\n+\t}\n+}","is_error":false}]},"uuid":"96c1c7c2-e28b-4dd7-8e6b-58a944086424","timestamp":"2026-02-08T19:35:35.941Z","sourceToolAssistantUUID":"e422e1f1-4210-4189-a2c6-3750d8e0cf59"} {"parentUuid":"96c1c7c2-e28b-4dd7-8e6b-58a944086424","isSidechain":true,"userType":"external","cwd":"/home/josie/development/kubernetes/karpenter-provider-ibm-cloud","sessionId":"8bfa3daa-29ad-4a1c-a335-851615a77550","version":"2.1.37","gitBranch":"fix/pre-commit-hooks","agentId":"ab458fe","message":{"model":"claude-haiku-4-5-20251001","id":"msg_018YkZNiLB61DeUft7bq5tju","type":"message","role":"assistant","content":[{"type":"text","text":"## Summary of PR #505: Instance Caching\n\n**What the PR does:**\nAdds an in-memory caching mechanism for VPC instances to reduce API calls and improve performance. The PR implements a cache that stores instance data locally and invalidates entries appropriately (e.g., when instances are deleted or undergoing deletion).\n\n**Files modified:**\n1. `pkg/cloudprovider/ibm/client.go` - Added `ExtractRegionFromZone()` utility function\n2. `pkg/controllers/nodeclass/status/controller.go` - Refactored to use the new `ExtractRegionFromZone()` from the client package\n3. `pkg/providers/vpc/instance/provider.go` - Main implementation of instance caching with:\n - New `instanceCache` field added to `VPCInstanceProvider`\n - `WithInstanceCache()` option for dependency injection\n - Cache integration in `Get()`, `List()`, and `Delete()` methods\n4. `pkg/providers/vpc/instance/provider_test.go` - Comprehensive test coverage including:\n - Cache hit scenarios\n - Cache invalidation on delete\n - Cache population during List operations\n - Handling of deleting instances (not cached)\n - Invalid cache entry recovery\n - Region extraction from zone names\n5. `pkg/utils/vpcclient/manager.go` - Added `NewManagerWithMockClient()` helper for testing\n\n**Key changes in the diff:**\n- Extracted `ExtractRegionFromZone()` to `pkg/cloudprovider/ibm/client.go` for reusability\n- Moved zone-to-region extraction from hardcoded \"region\" string to dynamic extraction in `List()`\n- Instances are cached after successful API retrieval but NOT cached if they're in \"deleting\" status\n- Cache is invalidated when instances are deleted\n- Cache uses type assertion with fallback fetching if invalid entries exist\n- 5 comprehensive test cases added to verify caching behavior\n\n**Head commit SHA of the PR branch:**\nThe PR is currently OPEN and shows +387 additions, -21 deletions. The actual commit SHA isn't displayed in the output, but the PR is based on the main branch and closes issue #497."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":7579,"cache_read_input_tokens":4842,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":7579},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CXwASTTmm8qTCD85j9X27","type":"assistant","uuid":"ff15c290-147e-4e18-bde0-b19a2a9ec76b","timestamp":"2026-02-08T19:35:42.279Z"}