Skip to content
13 changes: 11 additions & 2 deletions pkg/nodeidentity/clusterapi/capimanager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/kops/pkg/nodeidentity/clusterapi"
"sigs.k8s.io/controller-runtime/pkg/client"
)
Expand All @@ -39,7 +40,11 @@ func NewManager(kubeClient client.Client) *Manager {
}
}

func (m *Manager) FindMachineByProviderID(ctx context.Context, providerID string) (*clusterapi.Machine, error) {
// FindMachineByProviderID returns the Machine with the given spec.providerID, or nil if not found.
// Machines belonging to CAPI clusters other than capiCluster are ignored; note that
// capiCluster.Name is the Machine's spec.clusterName (for CAPG, the kOps cluster name escaped with
// gce.SafeClusterName), not the kOps cluster name.
func (m *Manager) FindMachineByProviderID(ctx context.Context, providerID string, capiCluster types.NamespacedName) (*clusterapi.Machine, error) {
// TODO: Can we build an index
// selector := client.MatchingFieldsSelector{
// Selector: fields.OneTermEqualSelector("spec.providerID", providerID),
Expand All @@ -50,7 +55,7 @@ func (m *Manager) FindMachineByProviderID(ctx context.Context, providerID string
Kind: "Machine",
Version: "v1beta1",
})
if err := m.kubeClient.List(ctx, &machines); err != nil {
if err := m.kubeClient.List(ctx, &machines, client.InNamespace(capiCluster.Namespace)); err != nil {
return nil, fmt.Errorf("error listing machines: %w", err)
}
var matches []*unstructured.Unstructured
Expand All @@ -60,6 +65,10 @@ func (m *Manager) FindMachineByProviderID(ctx context.Context, providerID string
if machineSpecProviderID != providerID {
continue
}
machineClusterName, _, _ := unstructured.NestedString(machine.Object, "spec", "clusterName")
if machineClusterName != capiCluster.Name {
continue
}
matches = append(matches, machine)
}
if len(matches) > 0 {
Expand Down
111 changes: 9 additions & 102 deletions pkg/nodeidentity/gce/identify.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ import (
"context"
"fmt"
"os"
"strconv"
"strings"

"cloud.google.com/go/compute/metadata"
compute "google.golang.org/api/compute/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/klog/v2"
"k8s.io/kops/pkg/apis/kops"
"k8s.io/kops/pkg/nodeidentity"
Expand Down Expand Up @@ -132,7 +133,11 @@ func (i *nodeIdentifier) IdentifyNode(ctx context.Context, node *corev1.Node) (*
if i.capiManager != nil && capgRole != "" {
providerID := "gce://" + project + "/" + zone + "/" + instanceName

m, err := i.capiManager.FindMachineByProviderID(ctx, providerID)
capiCluster := types.NamespacedName{
Namespace: metav1.NamespaceSystem,
Name: gce.SafeClusterName(i.clusterName),
}
m, err := i.capiManager.FindMachineByProviderID(ctx, providerID, capiCluster)
if err != nil {
return nil, fmt.Errorf("error finding Machine with providerID %q: %w", providerID, err)
}
Expand All @@ -141,38 +146,12 @@ func (i *nodeIdentifier) IdentifyNode(ctx context.Context, node *corev1.Node) (*

var igName string
if capiMachine == nil {
// The metadata itself is potentially mutable from the instance
// We instead look at the MIG configuration
createdBy := getMetadataValue(instance.Metadata, "created-by")
if createdBy == "" {
return nil, fmt.Errorf("cannot find owner for instance %s", instance.Name)
}

// We need to double-check the MIG configuration, in case created-by was changed
migName := lastComponent(createdBy)

mig, err := i.getMIG(zone, migName)
instanceTemplate, err := gce.GetInstanceTemplateForMIGMember(ctx, i.computeService, i.project, instance)
if err != nil {
return nil, err
}

// We now double check that the instance is indeed managed by the MIG
// this can't be spoofed without GCE API access
migMember, err := i.getManagedInstance(ctx, mig, instance.Id)
if err != nil {
return nil, err
}

if migMember.Version == nil {
return nil, fmt.Errorf("instance %s did not have Version set", instance.Name)
}

instanceTemplate, err := i.getInstanceTemplate(lastComponent(migMember.Version.InstanceTemplate))
if err != nil {
return nil, err
}

igName = getMetadataValue(instanceTemplate.Properties.Metadata, MetadataKeyInstanceGroupName)
igName = gce.GetMetadataValue(instanceTemplate.Properties.Metadata, MetadataKeyInstanceGroupName)
if igName == "" {
return nil, fmt.Errorf("ig name not set on instance template %s", instanceTemplate.Name)
}
Expand Down Expand Up @@ -219,75 +198,3 @@ func (i *nodeIdentifier) getInstance(zone string, instanceName string) (*compute

return instance, nil
}

// getInstanceTemplate queries GCE for the IG Template with the specified name, returning an error if not found
func (i *nodeIdentifier) getInstanceTemplate(name string) (*compute.InstanceTemplate, error) {
t, err := i.computeService.InstanceTemplates.Get(i.project, name).Do()
if err != nil {
return nil, fmt.Errorf("error fetching GCE instance group template %q: %v", name, err)
}

return t, nil
}

// getMIG queries GCE for the MIG with the specified name, returning an error if not found
func (i *nodeIdentifier) getMIG(zone string, migName string) (*compute.InstanceGroupManager, error) {
mig, err := i.computeService.InstanceGroupManagers.Get(i.project, zone, migName).Do()
if err != nil {
return nil, fmt.Errorf("error fetching GCE managed instance group %q: %v", migName, err)
}

return mig, nil
}

// getManagedInstance queries GCE for the instance from the MIG
func (i *nodeIdentifier) getManagedInstance(ctx context.Context, mig *compute.InstanceGroupManager, instanceID uint64) (*compute.ManagedInstance, error) {
var matches []*compute.ManagedInstance

filter := "id=" + strconv.FormatUint(instanceID, 10)
zone := lastComponent(mig.Zone)
if err := i.computeService.InstanceGroupManagers.ListManagedInstances(i.project, zone, mig.Name).Filter(filter).Pages(ctx, func(page *compute.InstanceGroupManagersListManagedInstancesResponse) error {
// Post-filter... filters aren't implemented (b/27605549)
for _, instance := range page.ManagedInstances {
if instance.Id != instanceID {
continue
}
matches = append(matches, instance)
}
return nil
}); err != nil {
return nil, fmt.Errorf("error fetching GCE managed instance group members for %q: %v", mig.Name, err)
}

if len(matches) == 0 {
return nil, fmt.Errorf("instance %v not managed by mig %s", instanceID, mig.Name)
}
if len(matches) > 1 {
// Should be impossible - shows that filters / post-filters are not working
return nil, fmt.Errorf("found multiple instances with id %v managed by mig %s", instanceID, mig.Name)
}

return matches[0], nil
}

// lastComponent returns the last component of a URL, i.e. anything after the last slash
// If there is no slash, returns the whole string
func lastComponent(s string) string {
lastSlash := strings.LastIndex(s, "/")
if lastSlash != -1 {
s = s[lastSlash+1:]
}
return s
}

func getMetadataValue(metadata *compute.Metadata, key string) string {
value := ""
if metadata != nil {
for _, item := range metadata.Items {
if item.Key == key && item.Value != nil {
value = *item.Value
}
}
}
return value
}
96 changes: 96 additions & 0 deletions upup/pkg/fi/cloudup/gce/miginstance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
Copyright 2026 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package gce

import (
"context"
"fmt"
"strconv"

compute "google.golang.org/api/compute/v1"
)

// GetInstanceTemplateForMIGMember returns the instance template of the MIG that manages the given
// instance. The instance metadata is potentially mutable by whoever created the instance, so we
// instead resolve the MIG from the created-by metadata and verify that the instance is indeed
// managed by it; MIG membership can't be spoofed without GCE API access.
func GetInstanceTemplateForMIGMember(ctx context.Context, computeService *compute.Service, project string, instance *compute.Instance) (*compute.InstanceTemplate, error) {
createdBy := GetMetadataValue(instance.Metadata, "created-by")
if createdBy == "" {
return nil, fmt.Errorf("cannot find owner for instance %s", instance.Name)
}

// We need to double-check the MIG membership, in case created-by was changed
migName := LastComponent(createdBy)

migMember, err := getManagedInstance(ctx, computeService, project, migName, instance)
if err != nil {
return nil, err
}

if migMember.Version == nil {
return nil, fmt.Errorf("instance %s did not have Version set", instance.Name)
}

templateName := LastComponent(migMember.Version.InstanceTemplate)
instanceTemplate, err := computeService.InstanceTemplates.Get(project, templateName).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("error fetching GCE instance group template %q: %v", templateName, err)
}

return instanceTemplate, nil
}

// getManagedInstance queries GCE for the instance from the MIG
func getManagedInstance(ctx context.Context, computeService *compute.Service, project string, migName string, instance *compute.Instance) (*compute.ManagedInstance, error) {
zone := LastComponent(instance.Zone)
filter := "id=" + strconv.FormatUint(instance.Id, 10)
call := computeService.InstanceGroupManagers.ListManagedInstances(project, zone, migName).Filter(filter).Context(ctx)
for {
page, err := call.Do()
if err != nil {
return nil, fmt.Errorf("error fetching GCE managed instance group members for %q: %v", migName, err)
}

// Post-filter... filters aren't implemented (b/27605549)
for _, member := range page.ManagedInstances {
if member.Id == instance.Id {
return member, nil
}
}

if page.NextPageToken == "" {
break
}
call.PageToken(page.NextPageToken)
}

return nil, fmt.Errorf("instance %v not managed by mig %s", instance.Id, migName)
}

// GetMetadataValue returns the value for the given key in the metadata, or "" if not present.
func GetMetadataValue(metadata *compute.Metadata, key string) string {
value := ""
if metadata != nil {
for _, item := range metadata.Items {
if item.Key == key && item.Value != nil {
value = *item.Value
}
}
}
return value
}
87 changes: 87 additions & 0 deletions upup/pkg/fi/cloudup/gce/miginstance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
Copyright 2026 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package gce

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

compute "google.golang.org/api/compute/v1"
"google.golang.org/api/option"
)

func TestGetManagedInstanceStopsPaginationAfterMatch(t *testing.T) {
const (
project = "test-project"
zone = "test-zone"
mig = "test-mig"
instanceID = uint64(1234567890)
)

var pageTokens []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pageToken := r.URL.Query().Get("pageToken")
pageTokens = append(pageTokens, pageToken)

var response *compute.InstanceGroupManagersListManagedInstancesResponse
switch pageToken {
case "":
response = &compute.InstanceGroupManagersListManagedInstancesResponse{
ManagedInstances: []*compute.ManagedInstance{{Id: 1}},
NextPageToken: "second",
}
case "second":
response = &compute.InstanceGroupManagersListManagedInstancesResponse{
ManagedInstances: []*compute.ManagedInstance{{Id: instanceID}},
NextPageToken: "third",
}
default:
http.Error(w, "unexpected page token "+pageToken, http.StatusBadRequest)
return
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}))
t.Cleanup(server.Close)

computeService, err := compute.NewService(context.Background(), option.WithEndpoint(server.URL+"/"), option.WithoutAuthentication())
if err != nil {
t.Fatalf("building compute client: %v", err)
}

instance := &compute.Instance{
Id: instanceID,
Zone: "https://www.googleapis.com/compute/v1/projects/" + project + "/zones/" + zone,
}
member, err := getManagedInstance(context.Background(), computeService, project, mig, instance)
if err != nil {
t.Fatalf("getting managed instance: %v", err)
}
if member.Id != instanceID {
t.Errorf("expected instance ID %d, got %d", instanceID, member.Id)
}

if len(pageTokens) != 2 || pageTokens[0] != "" || pageTokens[1] != "second" {
t.Errorf("expected requests for the first two pages, got page tokens %q", pageTokens)
}
}
Loading
Loading