diff --git a/pkg/model/linodemodel/OWNERS b/pkg/model/linodemodel/OWNERS new file mode 100644 index 0000000000000..06183c48f9682 --- /dev/null +++ b/pkg/model/linodemodel/OWNERS @@ -0,0 +1,5 @@ +# See the OWNERS docs at + +labels: + +- area/provider/linode diff --git a/pkg/model/linodemodel/api_loadbalancer.go b/pkg/model/linodemodel/api_loadbalancer.go new file mode 100644 index 0000000000000..ff5d0db455cce --- /dev/null +++ b/pkg/model/linodemodel/api_loadbalancer.go @@ -0,0 +1,71 @@ +/* +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 linodemodel + +import ( + "fmt" + + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/dns" + "k8s.io/kops/pkg/wellknownservices" + "k8s.io/kops/upup/pkg/fi" + cloudlinode "k8s.io/kops/upup/pkg/fi/cloudup/linode" + "k8s.io/kops/upup/pkg/fi/cloudup/linodetasks" +) + +// APILoadBalancerModelBuilder builds a Linode (Akamai) load balancer for API access. +type APILoadBalancerModelBuilder struct { + *LinodeModelContext + Lifecycle fi.Lifecycle +} + +var _ fi.CloudupModelBuilder = &APILoadBalancerModelBuilder{} + +func (b *APILoadBalancerModelBuilder) Build(c *fi.CloudupModelBuilderContext) error { + if !b.UseLoadBalancerForAPI() { + return nil + } + + if len(b.Cluster.Spec.Networking.Subnets) == 0 || b.Cluster.Spec.Networking.Subnets[0].Region == "" { + return fmt.Errorf("linode API load balancer requires at least one subnet with a region") + } + + lb := &linodetasks.LoadBalancer{ + Name: fi.PtrTo("api." + b.ClusterName()), + Lifecycle: b.Lifecycle, + Region: fi.PtrTo(b.Cluster.Spec.Networking.Subnets[0].Region), + Tags: []string{ + cloudlinode.BuildLinodeTag(kops.LabelClusterName, b.ClusterName()), + }, + WellKnownServices: []wellknownservices.WellKnownService{wellknownservices.KubeAPIServer}, + } + + if dns.IsGossipClusterName(b.Cluster.Name) || b.Cluster.UsesPrivateDNS() || b.Cluster.UsesNoneDNS() { + lb.WellKnownServices = append(lb.WellKnownServices, wellknownservices.KopsController) + } + + c.AddTask(lb) + + backendReconcile := &linodetasks.LoadBalancerBackends{ + Name: fi.PtrTo("backends." + fi.ValueOf(lb.Name)), + Lifecycle: b.Lifecycle, + LoadBalancer: lb, + } + c.AddTask(backendReconcile) + + return nil +} diff --git a/pkg/model/linodemodel/context.go b/pkg/model/linodemodel/context.go new file mode 100644 index 0000000000000..ad2f626176574 --- /dev/null +++ b/pkg/model/linodemodel/context.go @@ -0,0 +1,24 @@ +/* +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 linodemodel + +import "k8s.io/kops/pkg/model" + +// LinodeModelContext holds shared model context for Linode (Akamai) builders. +type LinodeModelContext struct { + *model.KopsModelContext +} diff --git a/upup/pkg/fi/cloudup/linode/api_target.go b/upup/pkg/fi/cloudup/linode/api_target.go new file mode 100644 index 0000000000000..5d310795d2402 --- /dev/null +++ b/upup/pkg/fi/cloudup/linode/api_target.go @@ -0,0 +1,39 @@ +/* +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 linode + +import "k8s.io/kops/upup/pkg/fi" + +// APITarget runs cloudup tasks directly against Linode (Akamai) APIs. +type APITarget struct { + Cloud LinodeCloud +} + +var _ fi.CloudupTarget = &APITarget{} + +// NewAPITarget builds a Linode (Akamai) target implementation. +func NewAPITarget(cloud LinodeCloud) *APITarget { + return &APITarget{Cloud: cloud} +} + +func (t *APITarget) Finish(taskMap map[string]fi.CloudupTask) error { + return nil +} + +func (t *APITarget) DefaultCheckExisting() bool { + return true +} diff --git a/upup/pkg/fi/cloudup/linode/cloud.go b/upup/pkg/fi/cloudup/linode/cloud.go new file mode 100644 index 0000000000000..3297de74ac238 --- /dev/null +++ b/upup/pkg/fi/cloudup/linode/cloud.go @@ -0,0 +1,488 @@ +/* +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 linode + +import ( + "context" + "fmt" + "net" + "os" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/linode/linodego" + v1 "k8s.io/api/core/v1" + "k8s.io/klog/v2" + "k8s.io/kops/dnsprovider/pkg/dnsprovider" + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/cloudinstances" + "k8s.io/kops/pkg/truncate" + "k8s.io/kops/upup/pkg/fi" +) + +// LinodeClient is the Linode (Akamai) API surface used by cloudup tasks. +type LinodeClient interface { + ListSSHKeys(ctx context.Context, opts *linodego.ListOptions) ([]linodego.SSHKey, error) + CreateSSHKey(ctx context.Context, opts linodego.SSHKeyCreateOptions) (*linodego.SSHKey, error) + DeleteSSHKey(ctx context.Context, keyID int) error + ListInstances(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Instance, error) + CreateInstance(ctx context.Context, opts linodego.InstanceCreateOptions) (*linodego.Instance, error) + DeleteInstance(ctx context.Context, linodeID int) error + UpdateInstance(ctx context.Context, linodeID int, opts linodego.InstanceUpdateOptions) (*linodego.Instance, error) + ListVolumes(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Volume, error) + CreateVolume(ctx context.Context, opts linodego.VolumeCreateOptions) (*linodego.Volume, error) + DeleteVolume(ctx context.Context, volumeID int) error + ListNodeBalancers(ctx context.Context, opts *linodego.ListOptions) ([]linodego.NodeBalancer, error) + GetNodeBalancer(ctx context.Context, nodebalancerID int) (*linodego.NodeBalancer, error) + CreateNodeBalancer(ctx context.Context, opts linodego.NodeBalancerCreateOptions) (*linodego.NodeBalancer, error) + DeleteNodeBalancer(ctx context.Context, nodebalancerID int) error + ListNodeBalancerConfigs(ctx context.Context, nodebalancerID int, opts *linodego.ListOptions) ([]linodego.NodeBalancerConfig, error) + CreateNodeBalancerConfig(ctx context.Context, nodebalancerID int, opts linodego.NodeBalancerConfigCreateOptions) (*linodego.NodeBalancerConfig, error) + RebuildNodeBalancerConfig(ctx context.Context, nodebalancerID int, configID int, opts linodego.NodeBalancerConfigRebuildOptions) (*linodego.NodeBalancerConfig, error) + ListNodeBalancerNodes(ctx context.Context, nodebalancerID int, configID int, opts *linodego.ListOptions) ([]linodego.NodeBalancerNode, error) + CreateNodeBalancerNode(ctx context.Context, nodebalancerID int, configID int, opts linodego.NodeBalancerNodeCreateOptions) (*linodego.NodeBalancerNode, error) + UpdateNodeBalancerNode(ctx context.Context, nodebalancerID int, configID int, nodeID int, opts linodego.NodeBalancerNodeUpdateOptions) (*linodego.NodeBalancerNode, error) +} + +// LinodeCloud exposes cloud behavior required by cloudup for Linode (Akamai). +type LinodeCloud interface { + fi.Cloud + AccessToken() string + Client() LinodeClient +} + +var _ fi.Cloud = &Cloud{} + +// Cloud holds cloud-level behavior and credentials for Linode (Akamai) operations. +type Cloud struct { + accessToken string + region string + client LinodeClient +} + +const ( + TagKubernetesInstanceGroup = "kops.k8s.io/instance-group" + TagKubernetesInstanceRole = "kops.k8s.io/instance-role" + TagEtcdClusterName = "kops.k8s.io/etcd" +) + +// NewCloud builds a Linode (Akamai) cloud wrapper using LINODE_TOKEN. +func NewCloud(region string) (*Cloud, error) { + accessToken := os.Getenv("LINODE_TOKEN") + if accessToken == "" { + return nil, fmt.Errorf("LINODE_TOKEN is required") + } + if region == "" { + return nil, fmt.Errorf("region is required") + } + + linodeClient := linodego.NewClient(nil) + linodeClient.SetUserAgent("kops") + linodeClient.SetToken(accessToken) + + return &Cloud{ + accessToken: accessToken, + region: region, + client: &linodeClient, + }, nil +} + +// AccessToken returns the configured Linode (Akamai) API token. +func (c *Cloud) AccessToken() string { + return c.accessToken +} + +// Client returns the Linode (Akamai) API client. +func (c *Cloud) Client() LinodeClient { + return c.client +} + +// ProviderID returns the cloud provider ID for Linode (Akamai). +func (c *Cloud) ProviderID() kops.CloudProviderID { + return kops.CloudProviderLinode +} + +// DNS returns nil as DNS support has not yet been wired for Linode (Akamai). +func (c *Cloud) DNS() (dnsprovider.Interface, error) { + return nil, nil +} + +// FindVPCInfo returns VPC information for the given ID. This is currently not implemented for Linode (Akamai). +func (c *Cloud) FindVPCInfo(id string) (*fi.VPCInfo, error) { + return nil, nil +} + +// DeleteInstance deletes the given Linode (Akamai) instance. +func (c *Cloud) DeleteInstance(instance *cloudinstances.CloudInstance) error { + if instance == nil { + return fmt.Errorf("instance is required") + } + if c.client == nil { + return fmt.Errorf("linode client is not configured") + } + + instanceID, err := strconv.Atoi(instance.ID) + if err != nil { + return fmt.Errorf("invalid Linode (Akamai) instance ID %q: %w", instance.ID, err) + } + + if err := c.client.DeleteInstance(context.Background(), instanceID); err != nil { + if linodego.IsNotFound(err) { + klog.V(4).Infof("Linode (Akamai) instance %q was already deleted", instance.ID) + return nil + } + return fmt.Errorf("error deleting Linode (Akamai) instance %q: %w", instance.ID, err) + } + + return nil +} + +// DeregisterInstance deregisters the given Linode (Akamai) instance from the cloud provider. +func (c *Cloud) DeregisterInstance(instance *cloudinstances.CloudInstance) error { + return nil +} + +// DeleteGroup deletes all Linode (Akamai) instances that belong to the given instance group. +func (c *Cloud) DeleteGroup(group *cloudinstances.CloudInstanceGroup) error { + if group == nil || group.InstanceGroup == nil { + return fmt.Errorf("instance group is required") + } + if c.client == nil { + return fmt.Errorf("linode client is not configured") + } + + instances, err := c.client.ListInstances(context.Background(), nil) + if err != nil { + return fmt.Errorf("error listing Linode (Akamai) instances for group %q: %w", group.InstanceGroup.Name, err) + } + + instanceGroupTag := BuildLinodeTag(TagKubernetesInstanceGroup, group.InstanceGroup.Name) + clusterTag := "" + if group.InstanceGroup.Labels != nil { + if clusterName := group.InstanceGroup.Labels[kops.LabelClusterName]; clusterName != "" { + clusterTag = BuildLinodeTag(kops.LabelClusterName, clusterName) + } + } + + for _, instance := range instances { + if !slices.Contains(instance.Tags, instanceGroupTag) { + continue + } + if clusterTag != "" && !slices.Contains(instance.Tags, clusterTag) { + continue + } + + instanceID := strconv.Itoa(instance.ID) + if err := c.DeleteInstance(&cloudinstances.CloudInstance{ID: instanceID, CloudInstanceGroup: group}); err != nil { + return fmt.Errorf("error deleting Linode (Akamai) instance %q in group %q: %w", instanceID, group.InstanceGroup.Name, err) + } + } + + return nil +} + +// DetachInstance removes the role tag from the given Linode (Akamai) instance so that it is no longer +// considered part of a particular role in the cluster. +func (c *Cloud) DetachInstance(instance *cloudinstances.CloudInstance) error { + if instance == nil { + return fmt.Errorf("instance is required") + } + if c.client == nil { + return fmt.Errorf("Linode (Akamai) client is not configured") + } + + instanceID, err := strconv.Atoi(instance.ID) + if err != nil { + return fmt.Errorf("invalid Linode (Akamai) instance ID %q: %w", instance.ID, err) + } + + instances, err := c.client.ListInstances(context.Background(), nil) + if err != nil { + return fmt.Errorf("error listing Linode (Akamai) instances for detach %q: %w", instance.ID, err) + } + + var current *linodego.Instance + for i := range instances { + if instances[i].ID == instanceID { + current = &instances[i] + break + } + } + + if current == nil { + klog.V(4).Infof("Linode (Akamai) instance %q was already deleted before detach", instance.ID) + return nil + } + + updatedTags, removedRoleTag := removeTagsWithPrefix(current.Tags, BuildLinodeTag(TagKubernetesInstanceRole, "")) + if !removedRoleTag { + return nil + } + + if _, err := c.client.UpdateInstance(context.Background(), instanceID, linodego.InstanceUpdateOptions{Tags: &updatedTags}); err != nil { + if linodego.IsNotFound(err) { + klog.V(4).Infof("Linode (Akamai) instance %q was already deleted during detach", instance.ID) + return nil + } + return fmt.Errorf("error detaching Linode (Akamai) instance %q: %w", instance.ID, err) + } + + return nil +} + +// GetCloudGroups returns a map of cloud instance groups for the given cluster and instance groups, +// populated with the nodes that belong to each group. +func (c *Cloud) GetCloudGroups(cluster *kops.Cluster, instancegroups []*kops.InstanceGroup, warnUnmatched bool, nodes []v1.Node) (map[string]*cloudinstances.CloudInstanceGroup, error) { + nodeMap := cloudinstances.GetNodeMap(nodes, cluster) + + groups := make(map[string]*cloudinstances.CloudInstanceGroup, len(instancegroups)) + for _, ig := range instancegroups { + groups[ig.Name] = &cloudinstances.CloudInstanceGroup{ + HumanName: ig.Name, + InstanceGroup: ig, + MinSize: int(fi.ValueOf(ig.Spec.MinSize)), + TargetSize: int(fi.ValueOf(ig.Spec.MinSize)), + MaxSize: int(fi.ValueOf(ig.Spec.MaxSize)), + } + } + + for instanceID, node := range nodeMap { + igName := "" + if node.Labels != nil { + igName = node.Labels[kops.NodeLabelInstanceGroup] + } + if igName == "" { + if warnUnmatched { + klog.Warningf("node %q does not have the %q label", node.Name, kops.NodeLabelInstanceGroup) + } + continue + } + + group := groups[igName] + if group == nil { + if warnUnmatched { + klog.Warningf("node %q references unknown instance group %q", node.Name, igName) + } + continue + } + + if _, err := group.NewCloudInstance(instanceID, cloudinstances.CloudInstanceStatusUpToDate, node); err != nil { + return nil, err + } + } + + for _, group := range groups { + group.AdjustNeedUpdate() + } + + return groups, nil +} + +// Region returns the Linode (Akamai) region that this cloud is configured for. +func (c *Cloud) Region() string { + return c.region +} + +// FindClusterStatus returns the current status of the given cluster in Linode (Akamai). +func (c *Cloud) FindClusterStatus(cluster *kops.Cluster) (*kops.ClusterStatus, error) { + return &kops.ClusterStatus{}, nil +} + +// GetApiIngressStatus returns the API ingress status for the given cluster in Linode (Akamai). +func (c *Cloud) GetApiIngressStatus(cluster *kops.Cluster) ([]fi.ApiIngressStatus, error) { + var ingresses []fi.ApiIngressStatus + + publicName := cluster.Spec.API.PublicName + if publicName != "" { + if net.ParseIP(publicName) == nil { + ingresses = append(ingresses, fi.ApiIngressStatus{Hostname: publicName}) + } else { + ingresses = append(ingresses, fi.ApiIngressStatus{IP: publicName}) + } + + return ingresses, nil + } + + if cluster.Spec.API.LoadBalancer != nil && c.client != nil { + lbName := "api." + cluster.Name + label := NormalizedLoadBalancerLabel(lbName) + + nbs, err := c.client.ListNodeBalancers(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) load balancers: %w", err) + } + + for _, nb := range nbs { + if nb.Label != nil && *nb.Label == label { + if nb.IPv4 == nil || *nb.IPv4 == "" { + continue + } + ingresses = append(ingresses, fi.ApiIngressStatus{IP: *nb.IPv4}) + return ingresses, nil + } + } + + return ingresses, nil + } + + // Fallback: return control plane instance IPs + if c.client == nil { + return ingresses, nil + } + + instances, err := c.client.ListInstances(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) instances: %w", err) + } + + clusterTag := BuildLinodeTag(kops.LabelClusterName, cluster.Name) + controlPlaneTag := BuildLinodeTag(TagKubernetesInstanceRole, string(kops.InstanceGroupRoleControlPlane)) + apiServerTag := BuildLinodeTag(TagKubernetesInstanceRole, string(kops.InstanceGroupRoleAPIServer)) + + ipSet := make(map[string]struct{}) + for _, instance := range instances { + if !slices.Contains(instance.Tags, clusterTag) { + continue + } + if !slices.Contains(instance.Tags, controlPlaneTag) && !slices.Contains(instance.Tags, apiServerTag) { + continue + } + + ip := selectPublicIPv4(instance.IPv4) + if ip == "" { + continue + } + + ipSet[ip] = struct{}{} + } + + if len(ipSet) == 0 { + return ingresses, nil + } + + ips := make([]string, 0, len(ipSet)) + for ip := range ipSet { + ips = append(ips, ip) + } + slices.Sort(ips) + + for _, ip := range ips { + ingresses = append(ingresses, fi.ApiIngressStatus{IP: ip}) + } + + return ingresses, nil +} + +// BuildLinodeTag returns a Linode (Akamai) tag in the form "key:value". +func BuildLinodeTag(key, value string) string { + return key + ":" + value +} + +func removeTagsWithPrefix(tags []string, prefix string) ([]string, bool) { + filtered := make([]string, 0, len(tags)) + removed := false + + for _, tag := range tags { + if strings.HasPrefix(tag, prefix) { + removed = true + continue + } + filtered = append(filtered, tag) + } + + return filtered, removed +} + +// SelectIPv4 returns the first preferred IPv4 from ips. When preferPrivate is true +// the first private IPv4 is returned; otherwise the first public (non-private, +// non-loopback, non-link-local) IPv4. Falls back to the first any-IPv4. +func SelectIPv4(ips []*net.IP, preferPrivate bool) string { + var fallback string + + for _, ipPtr := range ips { + if ipPtr == nil { + continue + } + + ip := *ipPtr + if ip.To4() == nil { + continue + } + + if fallback == "" { + fallback = ip.String() + } + + if preferPrivate { + if ip.IsPrivate() { + return ip.String() + } + } else { + if !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() { + return ip.String() + } + } + } + + return fallback +} + +func selectPublicIPv4(ips []*net.IP) string { + return SelectIPv4(ips, false) +} + +// NormalizedLoadBalancerLabel returns a normalized label for a NodeBalancer name. +// NodeBalancer labels must match [A-Za-z0-9_-]+ and be 32 chars or fewer. +func NormalizedLoadBalancerLabel(name string) string { + label := invalidLinodeLabelChars.ReplaceAllString(name, "-") + label = strings.Trim(label, "-_") + if label == "" { + return "kops-api" + } + + if len(label) > 32 { + label = label[:32] + label = strings.Trim(label, "-_") + if label == "" { + return "kops-api" + } + } + + return label +} + +var invalidLinodeLabelChars = regexp.MustCompile(`[^A-Za-z0-9_-]+`) + +// NormalizeLinodeSSHKeyLabel returns a normalized label for a Linode (Akamai) SSH key. +// SSH key labels must match [A-Za-z0-9_-]+ and be 64 chars or fewer. +func NormalizeLinodeSSHKeyLabel(name string) string { + name = invalidLinodeLabelChars.ReplaceAllString(name, "-") + name = strings.Trim(name, "-_") + if name == "" { + return "kubernetes-ssh-key" + } + + name = truncate.TruncateString(name, truncate.TruncateStringOptions{MaxLength: 64, AlwaysAddHash: false}) + name = strings.Trim(name, "-_") + if name == "" { + return "kubernetes-ssh-key" + } + + return name +} diff --git a/upup/pkg/fi/cloudup/linode/cloud_test.go b/upup/pkg/fi/cloudup/linode/cloud_test.go new file mode 100644 index 0000000000000..6a1254cbd8b71 --- /dev/null +++ b/upup/pkg/fi/cloudup/linode/cloud_test.go @@ -0,0 +1,567 @@ +/* +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 linode + +import ( + "errors" + "net" + "reflect" + "sort" + "strings" + "testing" + + "github.com/linode/linodego" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/cloudinstances" + "k8s.io/kops/upup/pkg/fi" +) + +func ptrIP(s string) *net.IP { + ip := net.ParseIP(s) + if ip == nil { + return nil + } + return &ip +} + +func TestNewCloud(t *testing.T) { + tests := []struct { + name string + token string + region string + wantErrSub string + }{ + { + name: "requires LINODE_TOKEN", + region: "us-east", + wantErrSub: "LINODE_TOKEN is required", + }, + { + name: "requires region", + token: "test-token", + wantErrSub: "region is required", + }, + { + name: "builds cloud", + token: "test-token", + region: "us-east", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("LINODE_TOKEN", tt.token) + + cloud, err := NewCloud(tt.region) + if tt.wantErrSub != "" { + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErrSub) + } + if !strings.Contains(err.Error(), tt.wantErrSub) { + t.Fatalf("unexpected error: %v", err) + } + return + } + + if err != nil { + t.Fatalf("NewCloud returned error: %v", err) + } + if got, want := cloud.ProviderID(), kops.CloudProviderLinode; got != want { + t.Fatalf("provider mismatch: got %q, want %q", got, want) + } + if got, want := cloud.Region(), tt.region; got != want { + t.Fatalf("region mismatch: got %q, want %q", got, want) + } + if got, want := cloud.AccessToken(), tt.token; got != want { + t.Fatalf("access token mismatch: got %q, want %q", got, want) + } + if cloud.Client() == nil { + t.Fatalf("expected Linode client to be initialized") + } + }) + } +} + +func TestDeleteInstance(t *testing.T) { + t.Run("deletes instance", func(t *testing.T) { + client := &MockLinodeClient{} + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DeleteInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err != nil { + t.Fatalf("DeleteInstance returned error: %v", err) + } + + if got, want := client.DeletedInstanceIDs, []int{42}; !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected deleted IDs: got %v, want %v", got, want) + } + }) + + t.Run("rejects invalid id", func(t *testing.T) { + client := &MockLinodeClient{} + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DeleteInstance(&cloudinstances.CloudInstance{ID: "not-a-number"}) + if err == nil { + t.Fatalf("expected invalid ID error") + } + if !strings.Contains(err.Error(), "invalid Linode (Akamai) instance ID") { + t.Fatalf("unexpected error: %v", err) + } + + if len(client.DeletedInstanceIDs) != 0 { + t.Fatalf("expected no delete calls, got %v", client.DeletedInstanceIDs) + } + }) + + t.Run("ignores not found", func(t *testing.T) { + client := &MockLinodeClient{DeleteInstanceErrByID: map[int]error{ + 42: &linodego.Error{Code: 404, Message: "not found"}, + }} + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DeleteInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err != nil { + t.Fatalf("DeleteInstance returned error: %v", err) + } + + if got, want := client.DeletedInstanceIDs, []int{42}; !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected deleted IDs: got %v, want %v", got, want) + } + }) + + t.Run("returns API errors", func(t *testing.T) { + client := &MockLinodeClient{DeleteInstanceErrByID: map[int]error{ + 42: errors.New("api unavailable"), + }} + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DeleteInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err == nil { + t.Fatalf("expected delete error") + } + if !strings.Contains(err.Error(), "error deleting Linode (Akamai) instance") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestDeleteGroup(t *testing.T) { + t.Run("deletes instances for group and cluster", func(t *testing.T) { + client := &MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{ + {ID: 101, Tags: []string{"kops.k8s.io/instance-group:nodes-us-east", "kops.k8s.io/cluster:example.k8s.local"}}, + {ID: 102, Tags: []string{"kops.k8s.io/instance-group:nodes-us-east", "kops.k8s.io/cluster:example.k8s.local"}}, + {ID: 103, Tags: []string{"kops.k8s.io/instance-group:nodes-us-east", "kops.k8s.io/cluster:other.k8s.local"}}, + {ID: 104, Tags: []string{"kops.k8s.io/instance-group:control-plane-us-east", "kops.k8s.io/cluster:example.k8s.local"}}, + }, + } + cloud := &Cloud{region: "us-east", client: client} + + group := &cloudinstances.CloudInstanceGroup{ + InstanceGroup: &kops.InstanceGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nodes-us-east", + Labels: map[string]string{ + kops.LabelClusterName: "example.k8s.local", + }, + }, + }, + } + + err := cloud.DeleteGroup(group) + if err != nil { + t.Fatalf("DeleteGroup returned error: %v", err) + } + + sort.Ints(client.DeletedInstanceIDs) + if got, want := client.DeletedInstanceIDs, []int{101, 102}; !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected deleted IDs: got %v, want %v", got, want) + } + }) + + t.Run("deletes by instance-group tag when cluster label is missing", func(t *testing.T) { + client := &MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{ + {ID: 101, Tags: []string{"kops.k8s.io/instance-group:nodes-us-east", "kops.k8s.io/cluster:example.k8s.local"}}, + {ID: 102, Tags: []string{"kops.k8s.io/instance-group:nodes-us-east", "kops.k8s.io/cluster:other.k8s.local"}}, + {ID: 103, Tags: []string{"kops.k8s.io/instance-group:control-plane-us-east", "kops.k8s.io/cluster:example.k8s.local"}}, + }, + } + cloud := &Cloud{region: "us-east", client: client} + + group := &cloudinstances.CloudInstanceGroup{ + InstanceGroup: &kops.InstanceGroup{ObjectMeta: metav1.ObjectMeta{Name: "nodes-us-east"}}, + } + + err := cloud.DeleteGroup(group) + if err != nil { + t.Fatalf("DeleteGroup returned error: %v", err) + } + + sort.Ints(client.DeletedInstanceIDs) + if got, want := client.DeletedInstanceIDs, []int{101, 102}; !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected deleted IDs: got %v, want %v", got, want) + } + }) + + t.Run("returns list errors", func(t *testing.T) { + client := &MockLinodeClient{ListInstancesError: errors.New("api unavailable")} + cloud := &Cloud{region: "us-east", client: client} + + group := &cloudinstances.CloudInstanceGroup{InstanceGroup: &kops.InstanceGroup{ObjectMeta: metav1.ObjectMeta{Name: "nodes-us-east"}}} + err := cloud.DeleteGroup(group) + if err == nil { + t.Fatalf("expected list error") + } + if !strings.Contains(err.Error(), "error listing Linode (Akamai) instances") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("returns delete errors", func(t *testing.T) { + client := &MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{{ID: 101, Tags: []string{"kops.k8s.io/instance-group:nodes-us-east"}}}, + DeleteInstanceErrByID: map[int]error{101: errors.New("api unavailable")}, + } + cloud := &Cloud{region: "us-east", client: client} + + group := &cloudinstances.CloudInstanceGroup{InstanceGroup: &kops.InstanceGroup{ObjectMeta: metav1.ObjectMeta{Name: "nodes-us-east"}}} + err := cloud.DeleteGroup(group) + if err == nil { + t.Fatalf("expected delete error") + } + if !strings.Contains(err.Error(), "error deleting Linode (Akamai) instance") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestDetachInstance(t *testing.T) { + t.Run("removes instance-role tag", func(t *testing.T) { + client := &MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{{ + ID: 42, + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-group:nodes-us-east", "kops.k8s.io/instance-role:Node", "env:test"}, + }}, + } + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DetachInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err != nil { + t.Fatalf("DetachInstance returned error: %v", err) + } + + if got, want := client.UpdatedInstanceIDs, []int{42}; !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected updated IDs: got %v, want %v", got, want) + } + + wantTags := []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-group:nodes-us-east", "env:test"} + if got := client.UpdatedTagsByID[42]; !reflect.DeepEqual(got, wantTags) { + t.Fatalf("unexpected updated tags: got %v, want %v", got, wantTags) + } + }) + + t.Run("returns nil when instance already missing", func(t *testing.T) { + client := &MockLinodeClient{ListInstancesResponse: []linodego.Instance{{ID: 101, Tags: []string{"kops.k8s.io/instance-role:Node"}}}} + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DetachInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err != nil { + t.Fatalf("DetachInstance returned error: %v", err) + } + if len(client.UpdatedInstanceIDs) != 0 { + t.Fatalf("expected no update calls, got %v", client.UpdatedInstanceIDs) + } + }) + + t.Run("returns nil when no instance-role tag", func(t *testing.T) { + client := &MockLinodeClient{ListInstancesResponse: []linodego.Instance{{ + ID: 42, + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-group:nodes-us-east"}, + }}} + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DetachInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err != nil { + t.Fatalf("DetachInstance returned error: %v", err) + } + if len(client.UpdatedInstanceIDs) != 0 { + t.Fatalf("expected no update calls, got %v", client.UpdatedInstanceIDs) + } + }) + + t.Run("rejects invalid id", func(t *testing.T) { + client := &MockLinodeClient{} + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DetachInstance(&cloudinstances.CloudInstance{ID: "not-a-number"}) + if err == nil { + t.Fatalf("expected invalid ID error") + } + if !strings.Contains(err.Error(), "invalid Linode (Akamai) instance ID") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("returns list errors", func(t *testing.T) { + client := &MockLinodeClient{ListInstancesError: errors.New("api unavailable")} + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DetachInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err == nil { + t.Fatalf("expected list error") + } + if !strings.Contains(err.Error(), "error listing Linode (Akamai) instances for detach") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("ignores not found update errors", func(t *testing.T) { + client := &MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{{ID: 42, Tags: []string{"kops.k8s.io/instance-role:Node"}}}, + UpdateInstanceErrByID: map[int]error{42: &linodego.Error{Code: 404, Message: "not found"}}, + } + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DetachInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err != nil { + t.Fatalf("DetachInstance returned error: %v", err) + } + }) + + t.Run("returns update errors", func(t *testing.T) { + client := &MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{{ID: 42, Tags: []string{"kops.k8s.io/instance-role:Node"}}}, + UpdateInstanceErrByID: map[int]error{42: errors.New("api unavailable")}, + } + cloud := &Cloud{region: "us-east", client: client} + + err := cloud.DetachInstance(&cloudinstances.CloudInstance{ID: "42"}) + if err == nil { + t.Fatalf("expected update error") + } + if !strings.Contains(err.Error(), "error detaching Linode (Akamai) instance") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestGetApiIngressStatus(t *testing.T) { + tests := []struct { + name string + publicName string + loadBalancer bool + client LinodeClient + wantHosts []string + wantIPs []string + wantErrSub string + }{ + { + name: "hostname ingress", + publicName: "api.example.test", + wantHosts: []string{"api.example.test"}, + }, + { + name: "ip ingress", + publicName: "203.0.113.15", + wantIPs: []string{"203.0.113.15"}, + }, + { + name: "load balancer ingress", + loadBalancer: true, + client: &MockLinodeClient{ + ListNodeBalancersResponse: []linodego.NodeBalancer{ + { + Label: fi.PtrTo("api-example-k8s-local"), + IPv4: fi.PtrTo("203.0.113.20"), + }, + }, + }, + wantIPs: []string{"203.0.113.20"}, + }, + { + name: "empty ingress when load balancer not created yet", + loadBalancer: true, + client: &MockLinodeClient{ListInstancesResponse: []linodego.Instance{ + { + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-role:ControlPlane"}, + IPv4: []*net.IP{ptrIP("10.0.0.10"), ptrIP("198.51.100.10")}, + }, + { + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-role:APIServer"}, + IPv4: []*net.IP{ptrIP("198.51.100.11")}, + }, + }}, + // When LoadBalancer is configured but doesn't exist yet, return empty (not fallback to instances) + }, + { + name: "control-plane ingress from instances when no load balancer", + client: &MockLinodeClient{ListInstancesResponse: []linodego.Instance{ + { + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-role:ControlPlane"}, + IPv4: []*net.IP{ptrIP("10.0.0.10"), ptrIP("198.51.100.10")}, + }, + { + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-role:APIServer"}, + IPv4: []*net.IP{ptrIP("198.51.100.11")}, + }, + { + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-role:Node"}, + IPv4: []*net.IP{ptrIP("198.51.100.12")}, + }, + }}, + wantIPs: []string{"198.51.100.10", "198.51.100.11"}, + }, + { + name: "empty ingress when no api endpoint data", + loadBalancer: true, + client: &MockLinodeClient{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cloud := &Cloud{region: "us-east", client: tt.client} + cluster := &kops.Cluster{ + ObjectMeta: metav1.ObjectMeta{Name: "example.k8s.local"}, + Spec: kops.ClusterSpec{API: kops.APISpec{PublicName: tt.publicName}}, + } + if tt.loadBalancer { + cluster.Spec.API.LoadBalancer = &kops.LoadBalancerAccessSpec{} + } + + ingresses, err := cloud.GetApiIngressStatus(cluster) + if tt.wantErrSub != "" { + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErrSub) + } + if !strings.Contains(err.Error(), tt.wantErrSub) { + t.Fatalf("unexpected error: %v", err) + } + return + } + + if err != nil { + t.Fatalf("GetApiIngressStatus returned error: %v", err) + } + + var gotHosts []string + var gotIPs []string + for _, ingress := range ingresses { + if ingress.Hostname != "" { + gotHosts = append(gotHosts, ingress.Hostname) + } + if ingress.IP != "" { + gotIPs = append(gotIPs, ingress.IP) + } + } + sort.Strings(gotHosts) + sort.Strings(gotIPs) + + wantHosts := append([]string(nil), tt.wantHosts...) + wantIPs := append([]string(nil), tt.wantIPs...) + sort.Strings(wantHosts) + sort.Strings(wantIPs) + + if !reflect.DeepEqual(gotHosts, wantHosts) { + t.Fatalf("unexpected hostnames: got %v, want %v", gotHosts, wantHosts) + } + if !reflect.DeepEqual(gotIPs, wantIPs) { + t.Fatalf("unexpected IPs: got %v, want %v", gotIPs, wantIPs) + } + }) + } +} + +func TestGetCloudGroups(t *testing.T) { + cloud := &Cloud{region: "us-east"} + cluster := &kops.Cluster{ + Spec: kops.ClusterSpec{ + CloudProvider: kops.CloudProviderSpec{ + Linode: &kops.LinodeSpec{}, + }, + }, + } + + instanceGroups := []*kops.InstanceGroup{ + {ObjectMeta: metav1.ObjectMeta{Name: "control-plane-us-east"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "nodes-us-east"}}, + } + + nodes := []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "cp-1", + Labels: map[string]string{ + kops.NodeLabelInstanceGroup: "control-plane-us-east", + }, + }, + Spec: v1.NodeSpec{ProviderID: "linode:///111"}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Labels: map[string]string{ + kops.NodeLabelInstanceGroup: "nodes-us-east", + }, + Annotations: map[string]string{ + "kops.k8s.io/needs-update": "yes", + }, + }, + Spec: v1.NodeSpec{ProviderID: "linode:///222"}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "orphan-node", + Labels: map[string]string{ + kops.NodeLabelInstanceGroup: "unknown-group", + }, + }, + Spec: v1.NodeSpec{ProviderID: "linode:///333"}, + }, + } + + groups, err := cloud.GetCloudGroups(cluster, instanceGroups, true, nodes) + if err != nil { + t.Fatalf("GetCloudGroups returned error: %v", err) + } + + cpGroup := groups["control-plane-us-east"] + if cpGroup == nil { + t.Fatalf("missing control-plane group") + } + if got, want := len(cpGroup.Ready), 1; got != want { + t.Fatalf("unexpected control-plane ready count: got %d, want %d", got, want) + } + if got, want := len(cpGroup.NeedUpdate), 0; got != want { + t.Fatalf("unexpected control-plane need-update count: got %d, want %d", got, want) + } + + nodesGroup := groups["nodes-us-east"] + if nodesGroup == nil { + t.Fatalf("missing nodes group") + } + if got, want := len(nodesGroup.Ready), 0; got != want { + t.Fatalf("unexpected nodes ready count: got %d, want %d", got, want) + } + if got, want := len(nodesGroup.NeedUpdate), 1; got != want { + t.Fatalf("unexpected nodes need-update count: got %d, want %d", got, want) + } +} diff --git a/upup/pkg/fi/cloudup/linode/mock_linode_cloud.go b/upup/pkg/fi/cloudup/linode/mock_linode_cloud.go new file mode 100644 index 0000000000000..9f58aa5f39096 --- /dev/null +++ b/upup/pkg/fi/cloudup/linode/mock_linode_cloud.go @@ -0,0 +1,261 @@ +/* +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 linode + +import ( + "context" + + "github.com/linode/linodego" + v1 "k8s.io/api/core/v1" + "k8s.io/kops/dnsprovider/pkg/dnsprovider" + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/cloudinstances" + "k8s.io/kops/upup/pkg/fi" +) + +// MockLinodeClient implements LinodeClient for use in tests. +type MockLinodeClient struct { + // SSH keys + ListSSHKeysResponse []linodego.SSHKey + ListSSHKeysError error + CreateSSHKeyResponse *linodego.SSHKey + CreateSSHKeyError error + CreateSSHKeyCalls int + LastCreateSSHKeyOpts linodego.SSHKeyCreateOptions + + // Instances + ListInstancesResponse []linodego.Instance + ListInstancesError error + CreateInstanceResponse *linodego.Instance + CreateInstanceError error + CreateInstanceCalls int + LastCreateInstanceOpts linodego.InstanceCreateOptions + DeleteInstanceErr error + DeleteInstanceErrByID map[int]error + DeletedInstanceIDs []int + UpdateInstanceErr error + UpdateInstanceErrByID map[int]error + UpdatedInstanceIDs []int + UpdatedTagsByID map[int][]string + + // Volumes + ListVolumesResponse []linodego.Volume + ListVolumesError error + CreateVolumeResponse *linodego.Volume + CreateVolumeError error + CreateVolumeCalls int + LastCreateVolumeOpts linodego.VolumeCreateOptions + + // NodeBalancers + ListNodeBalancersResponse []linodego.NodeBalancer + ListNodeBalancersError error + CreateNodeBalancerResponse *linodego.NodeBalancer + CreateNodeBalancerError error + CreateNodeBalancerCalls int + LastCreateNodeBalancerOpts linodego.NodeBalancerCreateOptions + + // NodeBalancer configs + ListNodeBalancerConfigsResponse []linodego.NodeBalancerConfig + ListNodeBalancerConfigsError error + CreateNodeBalancerConfigCalls int + CreateNodeBalancerConfigOpts []linodego.NodeBalancerConfigCreateOptions + RebuildNodeBalancerConfigCalls int + RebuildNodeBalancerConfigOpts []linodego.NodeBalancerConfigRebuildOptions + + // NodeBalancer nodes + ListNodeBalancerNodesResponse map[int][]linodego.NodeBalancerNode + CreateNodeBalancerNodeCalls int + CreateNodeBalancerNodeOpts []linodego.NodeBalancerNodeCreateOptions + UpdateNodeBalancerNodeCalls int + UpdateNodeBalancerNodeOpts []linodego.NodeBalancerNodeUpdateOptions +} + +var _ LinodeClient = &MockLinodeClient{} + +func (m *MockLinodeClient) ListSSHKeys(_ context.Context, _ *linodego.ListOptions) ([]linodego.SSHKey, error) { + return m.ListSSHKeysResponse, m.ListSSHKeysError +} + +func (m *MockLinodeClient) CreateSSHKey(_ context.Context, opts linodego.SSHKeyCreateOptions) (*linodego.SSHKey, error) { + m.CreateSSHKeyCalls++ + m.LastCreateSSHKeyOpts = opts + if m.CreateSSHKeyError != nil { + return nil, m.CreateSSHKeyError + } + if m.CreateSSHKeyResponse != nil { + return m.CreateSSHKeyResponse, nil + } + return &linodego.SSHKey{ID: 1, Label: opts.Label, SSHKey: opts.SSHKey}, nil +} + +func (m *MockLinodeClient) DeleteSSHKey(_ context.Context, _ int) error { return nil } + +func (m *MockLinodeClient) ListInstances(_ context.Context, _ *linodego.ListOptions) ([]linodego.Instance, error) { + return m.ListInstancesResponse, m.ListInstancesError +} + +func (m *MockLinodeClient) CreateInstance(_ context.Context, opts linodego.InstanceCreateOptions) (*linodego.Instance, error) { + m.CreateInstanceCalls++ + m.LastCreateInstanceOpts = opts + if m.CreateInstanceError != nil { + return nil, m.CreateInstanceError + } + if m.CreateInstanceResponse != nil { + return m.CreateInstanceResponse, nil + } + return &linodego.Instance{ID: 1, Label: opts.Label, Region: opts.Region, Type: opts.Type, Image: opts.Image, Tags: opts.Tags}, nil +} + +func (m *MockLinodeClient) DeleteInstance(_ context.Context, linodeID int) error { + m.DeletedInstanceIDs = append(m.DeletedInstanceIDs, linodeID) + if m.DeleteInstanceErrByID != nil { + if err := m.DeleteInstanceErrByID[linodeID]; err != nil { + return err + } + } + return m.DeleteInstanceErr +} + +func (m *MockLinodeClient) UpdateInstance(_ context.Context, linodeID int, opts linodego.InstanceUpdateOptions) (*linodego.Instance, error) { + m.UpdatedInstanceIDs = append(m.UpdatedInstanceIDs, linodeID) + if opts.Tags != nil { + if m.UpdatedTagsByID == nil { + m.UpdatedTagsByID = make(map[int][]string) + } + m.UpdatedTagsByID[linodeID] = append([]string(nil), (*opts.Tags)...) + } + if m.UpdateInstanceErrByID != nil { + if err := m.UpdateInstanceErrByID[linodeID]; err != nil { + return nil, err + } + } + if m.UpdateInstanceErr != nil { + return nil, m.UpdateInstanceErr + } + updated := &linodego.Instance{ID: linodeID} + if opts.Tags != nil { + updated.Tags = append([]string(nil), (*opts.Tags)...) + } + return updated, nil +} + +func (m *MockLinodeClient) ListVolumes(_ context.Context, _ *linodego.ListOptions) ([]linodego.Volume, error) { + return m.ListVolumesResponse, m.ListVolumesError +} + +func (m *MockLinodeClient) CreateVolume(_ context.Context, opts linodego.VolumeCreateOptions) (*linodego.Volume, error) { + m.CreateVolumeCalls++ + m.LastCreateVolumeOpts = opts + if m.CreateVolumeError != nil { + return nil, m.CreateVolumeError + } + if m.CreateVolumeResponse != nil { + return m.CreateVolumeResponse, nil + } + return &linodego.Volume{ID: 1, Label: opts.Label, Region: opts.Region, Size: opts.Size, Tags: opts.Tags}, nil +} + +func (m *MockLinodeClient) DeleteVolume(_ context.Context, _ int) error { return nil } + +func (m *MockLinodeClient) ListNodeBalancers(_ context.Context, _ *linodego.ListOptions) ([]linodego.NodeBalancer, error) { + return m.ListNodeBalancersResponse, m.ListNodeBalancersError +} + +func (m *MockLinodeClient) GetNodeBalancer(_ context.Context, nodebalancerID int) (*linodego.NodeBalancer, error) { + for _, nb := range m.ListNodeBalancersResponse { + if nb.ID == nodebalancerID { + return &nb, nil + } + } + return nil, nil +} + +func (m *MockLinodeClient) CreateNodeBalancer(_ context.Context, opts linodego.NodeBalancerCreateOptions) (*linodego.NodeBalancer, error) { + m.CreateNodeBalancerCalls++ + m.LastCreateNodeBalancerOpts = opts + if m.CreateNodeBalancerError != nil { + return nil, m.CreateNodeBalancerError + } + if m.CreateNodeBalancerResponse != nil { + return m.CreateNodeBalancerResponse, nil + } + return &linodego.NodeBalancer{ID: 1, Label: opts.Label, Region: opts.Region, Tags: opts.Tags}, nil +} + +func (m *MockLinodeClient) DeleteNodeBalancer(_ context.Context, _ int) error { return nil } + +func (m *MockLinodeClient) ListNodeBalancerConfigs(_ context.Context, _ int, _ *linodego.ListOptions) ([]linodego.NodeBalancerConfig, error) { + return m.ListNodeBalancerConfigsResponse, m.ListNodeBalancerConfigsError +} + +func (m *MockLinodeClient) CreateNodeBalancerConfig(_ context.Context, _ int, opts linodego.NodeBalancerConfigCreateOptions) (*linodego.NodeBalancerConfig, error) { + m.CreateNodeBalancerConfigCalls++ + m.CreateNodeBalancerConfigOpts = append(m.CreateNodeBalancerConfigOpts, opts) + return &linodego.NodeBalancerConfig{ID: m.CreateNodeBalancerConfigCalls, Port: opts.Port}, nil +} + +func (m *MockLinodeClient) RebuildNodeBalancerConfig(_ context.Context, _ int, configID int, opts linodego.NodeBalancerConfigRebuildOptions) (*linodego.NodeBalancerConfig, error) { + m.RebuildNodeBalancerConfigCalls++ + m.RebuildNodeBalancerConfigOpts = append(m.RebuildNodeBalancerConfigOpts, opts) + return &linodego.NodeBalancerConfig{ID: configID, Port: opts.Port}, nil +} + +func (m *MockLinodeClient) ListNodeBalancerNodes(_ context.Context, _ int, configID int, _ *linodego.ListOptions) ([]linodego.NodeBalancerNode, error) { + if m.ListNodeBalancerNodesResponse == nil { + return nil, nil + } + return m.ListNodeBalancerNodesResponse[configID], nil +} + +func (m *MockLinodeClient) CreateNodeBalancerNode(_ context.Context, nodebalancerID int, configID int, opts linodego.NodeBalancerNodeCreateOptions) (*linodego.NodeBalancerNode, error) { + m.CreateNodeBalancerNodeCalls++ + m.CreateNodeBalancerNodeOpts = append(m.CreateNodeBalancerNodeOpts, opts) + return &linodego.NodeBalancerNode{ID: m.CreateNodeBalancerNodeCalls, Address: opts.Address, Label: opts.Label, Mode: opts.Mode, ConfigID: configID, NodeBalancerID: nodebalancerID}, nil +} + +func (m *MockLinodeClient) UpdateNodeBalancerNode(_ context.Context, nodebalancerID int, configID int, nodeID int, opts linodego.NodeBalancerNodeUpdateOptions) (*linodego.NodeBalancerNode, error) { + m.UpdateNodeBalancerNodeCalls++ + m.UpdateNodeBalancerNodeOpts = append(m.UpdateNodeBalancerNodeOpts, opts) + return &linodego.NodeBalancerNode{ID: nodeID, Address: opts.Address, Label: opts.Label, Mode: opts.Mode, Weight: opts.Weight, ConfigID: configID, NodeBalancerID: nodebalancerID}, nil +} + +// MockLinodeCloud implements LinodeCloud for use in tests. +type MockLinodeCloud struct { + Client_ *MockLinodeClient +} + +var _ LinodeCloud = &MockLinodeCloud{} + +func (m *MockLinodeCloud) ProviderID() kops.CloudProviderID { return kops.CloudProviderLinode } +func (m *MockLinodeCloud) DNS() (dnsprovider.Interface, error) { return nil, nil } +func (m *MockLinodeCloud) FindVPCInfo(_ string) (*fi.VPCInfo, error) { return nil, nil } +func (m *MockLinodeCloud) DeleteInstance(_ *cloudinstances.CloudInstance) error { return nil } +func (m *MockLinodeCloud) DeregisterInstance(_ *cloudinstances.CloudInstance) error { return nil } +func (m *MockLinodeCloud) DeleteGroup(_ *cloudinstances.CloudInstanceGroup) error { return nil } +func (m *MockLinodeCloud) DetachInstance(_ *cloudinstances.CloudInstance) error { return nil } +func (m *MockLinodeCloud) GetCloudGroups(_ *kops.Cluster, _ []*kops.InstanceGroup, _ bool, _ []v1.Node) (map[string]*cloudinstances.CloudInstanceGroup, error) { + return nil, nil +} +func (m *MockLinodeCloud) Region() string { return "us-east" } +func (m *MockLinodeCloud) FindClusterStatus(_ *kops.Cluster) (*kops.ClusterStatus, error) { + return &kops.ClusterStatus{}, nil +} +func (m *MockLinodeCloud) GetApiIngressStatus(_ *kops.Cluster) ([]fi.ApiIngressStatus, error) { + return nil, nil +} +func (m *MockLinodeCloud) AccessToken() string { return "test-token" } +func (m *MockLinodeCloud) Client() LinodeClient { return m.Client_ } diff --git a/upup/pkg/fi/cloudup/linode/verifier.go b/upup/pkg/fi/cloudup/linode/verifier.go index 821a476639de6..7eff8bfd1a03a 100644 --- a/upup/pkg/fi/cloudup/linode/verifier.go +++ b/upup/pkg/fi/cloudup/linode/verifier.go @@ -32,11 +32,6 @@ import ( type LinodeVerifierOptions struct{} -const ( - TagKubernetesInstanceGroup = "kops.k8s.io/instance-group" - TagKubernetesInstanceRole = "kops.k8s.io/instance-role" -) - type linodeVerifierClient interface { GetInstance(ctx context.Context, linodeID int) (*linodego.Instance, error) } diff --git a/upup/pkg/fi/cloudup/linodetasks/OWNERS b/upup/pkg/fi/cloudup/linodetasks/OWNERS new file mode 100644 index 0000000000000..21222f2947b5e --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/OWNERS @@ -0,0 +1,3 @@ +# See the OWNERS docs at https://go.k8s.io/owners +labels: +- area/provider/linode diff --git a/upup/pkg/fi/cloudup/linodetasks/loadbalancer.go b/upup/pkg/fi/cloudup/linodetasks/loadbalancer.go new file mode 100644 index 0000000000000..d634cc62c5ad8 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/loadbalancer.go @@ -0,0 +1,337 @@ +/* +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 linodetasks + +import ( + "context" + "fmt" + "net" + "slices" + "strconv" + "strings" + + "github.com/linode/linodego" + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/wellknownports" + "k8s.io/kops/pkg/wellknownservices" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +// +kops:fitask +type LoadBalancer struct { + Name *string + Lifecycle fi.Lifecycle + + ID *int + Region *string + Tags []string + + // WellKnownServices indicates which services are supported by this resource. + // This field is internal and is not rendered to the cloud. + WellKnownServices []wellknownservices.WellKnownService +} + +var _ fi.CloudupTask = &LoadBalancer{} +var _ fi.CompareWithID = &LoadBalancer{} +var _ fi.HasAddress = &LoadBalancer{} + +func (l *LoadBalancer) CompareWithID() *string { + if l.ID == nil { + return nil + } + id := strconv.Itoa(fi.ValueOf(l.ID)) + return fi.PtrTo(id) +} + +// GetWellKnownServices returns the well-known services this load balancer provides. +func (l *LoadBalancer) GetWellKnownServices() []wellknownservices.WellKnownService { + return l.WellKnownServices +} + +func (l *LoadBalancer) FindAddresses(c *fi.CloudupContext) ([]string, error) { + actual, err := l.Find(c) + if err != nil { + return nil, err + } + if actual == nil { + return nil, nil + } + + cloud := c.T.Cloud.(linode.LinodeCloud) + nodebalancer, err := cloud.Client().GetNodeBalancer(c.Context(), fi.ValueOf(actual.ID)) + if err != nil { + if linodego.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("error getting Linode (Akamai) load balancer %q: %w", fi.ValueOf(l.Name), err) + } + if nodebalancer == nil || nodebalancer.IPv4 == nil || *nodebalancer.IPv4 == "" { + return nil, nil + } + + return []string{*nodebalancer.IPv4}, nil +} + +func (l *LoadBalancer) Find(c *fi.CloudupContext) (*LoadBalancer, error) { + cloud := c.T.Cloud.(linode.LinodeCloud) + nodebalancers, err := cloud.Client().ListNodeBalancers(c.Context(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) load balancers: %w", err) + } + + taskLabel := linode.NormalizedLoadBalancerLabel(fi.ValueOf(l.Name)) + for i := range nodebalancers { + nb := nodebalancers[i] + if fi.ValueOf(nb.Label) != taskLabel { + continue + } + + actual := &LoadBalancer{ + // Preserve desired task identity to avoid a synthetic Name change when + // the cloud label is normalized from the desired DNS-like name. + Name: l.Name, + Lifecycle: l.Lifecycle, + ID: fi.PtrTo(nb.ID), + Region: fi.PtrTo(nb.Region), + Tags: slices.Clone(nb.Tags), + WellKnownServices: slices.Clone(l.WellKnownServices), + } + l.ID = actual.ID + return actual, nil + } + + return nil, nil +} + +func (l *LoadBalancer) Run(c *fi.CloudupContext) error { + return fi.CloudupDefaultDeltaRunMethod(l, c) +} + +func (_ *LoadBalancer) CheckChanges(a, e, changes *LoadBalancer) error { + if a != nil { + if changes.Name != nil { + return fi.CannotChangeField("Name") + } + if changes.ID != nil { + return fi.CannotChangeField("ID") + } + if changes.Region != nil { + return fi.CannotChangeField("Region") + } + } else { + if e.Name == nil { + return fi.RequiredField("Name") + } + if e.Region == nil { + return fi.RequiredField("Region") + } + } + + return nil +} + +func (_ *LoadBalancer) RenderLinode(t *linode.APITarget, a, e, changes *LoadBalancer) error { + backends, err := linodeDiscoverControlPlaneBackends(t.Cloud.Client(), e.Tags) + if err != nil { + return err + } + + label := linode.NormalizedLoadBalancerLabel(fi.ValueOf(e.Name)) + + if a == nil { + nb, err := t.Cloud.Client().CreateNodeBalancer(context.Background(), linodego.NodeBalancerCreateOptions{ + Label: fi.PtrTo(label), + Region: fi.ValueOf(e.Region), + Tags: slices.Clone(e.Tags), + Type: linodego.NBTypeCommon, + Configs: nil, + }) + if err != nil { + return fmt.Errorf("error creating Linode (Akamai) load balancer %q: %w", fi.ValueOf(e.Name), err) + } + + e.ID = fi.PtrTo(nb.ID) + if len(backends) == 0 { + return nil + } + + return ensureLoadBalancerConfigs(t.Cloud.Client(), nb.ID, fi.ValueOf(e.Name), backends) + } + + if len(backends) == 0 { + return nil + } + + return ensureLoadBalancerConfigs(t.Cloud.Client(), fi.ValueOf(a.ID), fi.ValueOf(e.Name), backends) +} + +func ensureLoadBalancerConfigs(client linode.LinodeClient, nodebalancerID int, name string, backends []string) error { + configs, err := client.ListNodeBalancerConfigs(context.Background(), nodebalancerID, nil) + if err != nil { + return fmt.Errorf("error listing Linode (Akamai) load balancer configs for %q: %w", name, err) + } + + configByPort := map[int]linodego.NodeBalancerConfig{} + for _, cfg := range configs { + configByPort[cfg.Port] = cfg + } + + for _, port := range []int{wellknownports.KubeAPIServer, wellknownports.KopsControllerPort} { + nodes := linodeCreateNodeOptions(backends, port) + var configID int + if cfg, found := configByPort[port]; found { + configID = cfg.ID + _, err := client.RebuildNodeBalancerConfig(context.Background(), nodebalancerID, cfg.ID, linodego.NodeBalancerConfigRebuildOptions{ + Port: port, + Protocol: linodego.ProtocolTCP, + Check: linodego.CheckConnection, + Algorithm: linodego.AlgorithmRoundRobin, + Nodes: linodeCreateRebuildNodeOptions(nodes), + }) + if err != nil { + return fmt.Errorf("error rebuilding Linode (Akamai) load balancer config for port %d: %w", port, err) + } + } else { + createdConfig, err := client.CreateNodeBalancerConfig(context.Background(), nodebalancerID, *linodeCreateTCPConfig(port, backends)) + if err != nil { + return fmt.Errorf("error creating Linode (Akamai) load balancer config for port %d: %w", port, err) + } + configID = createdConfig.ID + } + + if err := ensureLoadBalancerConfigNodes(client, nodebalancerID, configID, nodes); err != nil { + return fmt.Errorf("error reconciling Linode (Akamai) load balancer nodes for port %d: %w", port, err) + } + } + + return nil +} + +func ensureLoadBalancerConfigNodes(client linode.LinodeClient, nodebalancerID int, configID int, desiredNodes []linodego.NodeBalancerNodeCreateOptions) error { + existingNodes, err := client.ListNodeBalancerNodes(context.Background(), nodebalancerID, configID, nil) + if err != nil { + return fmt.Errorf("error listing Linode (Akamai) load balancer config nodes: %w", err) + } + + desiredByAddress := make(map[string]linodego.NodeBalancerNodeCreateOptions, len(desiredNodes)) + for _, desired := range desiredNodes { + desiredByAddress[desired.Address] = desired + } + + for _, existing := range existingNodes { + desired, found := desiredByAddress[existing.Address] + if !found { + continue + } + + if existing.Label != desired.Label || existing.Mode != desired.Mode || existing.Weight != desired.Weight { + _, err := client.UpdateNodeBalancerNode(context.Background(), nodebalancerID, configID, existing.ID, linodego.NodeBalancerNodeUpdateOptions{ + Address: desired.Address, + Label: desired.Label, + Mode: desired.Mode, + Weight: desired.Weight, + }) + if err != nil { + return fmt.Errorf("error updating Linode (Akamai) load balancer node %q: %w", existing.Address, err) + } + } + + delete(desiredByAddress, existing.Address) + } + + for _, desired := range desiredByAddress { + if _, err := client.CreateNodeBalancerNode(context.Background(), nodebalancerID, configID, desired); err != nil { + return fmt.Errorf("error creating Linode (Akamai) load balancer node %q: %w", desired.Address, err) + } + } + + return nil +} + +func linodeDiscoverControlPlaneBackends(client linode.LinodeClient, tags []string) ([]string, error) { + instances, err := client.ListInstances(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) instances for load balancer backends: %w", err) + } + + clusterTag := extractClusterTag(tags) + + controlPlaneTag := linode.BuildLinodeTag(linode.TagKubernetesInstanceRole, string(kops.InstanceGroupRoleControlPlane)) + apiServerTag := linode.BuildLinodeTag(linode.TagKubernetesInstanceRole, string(kops.InstanceGroupRoleAPIServer)) + + var backends []string + for _, instance := range instances { + if clusterTag != "" && !slices.Contains(instance.Tags, clusterTag) { + continue + } + if !slices.Contains(instance.Tags, controlPlaneTag) && !slices.Contains(instance.Tags, apiServerTag) { + continue + } + + if ip := selectPrivateIPv4(instance.IPv4); ip != "" { + backends = append(backends, ip) + } + } + + return backends, nil +} + +func extractClusterTag(tags []string) string { + for _, tag := range tags { + if strings.HasPrefix(tag, kops.LabelClusterName+":") { + return tag + } + } + + return "" +} + +func linodeCreateTCPConfig(port int, backends []string) *linodego.NodeBalancerConfigCreateOptions { + return &linodego.NodeBalancerConfigCreateOptions{ + Port: port, + Protocol: linodego.ProtocolTCP, + Check: linodego.CheckConnection, + Algorithm: linodego.AlgorithmRoundRobin, + Nodes: linodeCreateNodeOptions(backends, port), + } +} + +func linodeCreateNodeOptions(backends []string, port int) []linodego.NodeBalancerNodeCreateOptions { + nodes := make([]linodego.NodeBalancerNodeCreateOptions, 0, len(backends)) + for _, ip := range backends { + nodes = append(nodes, linodego.NodeBalancerNodeCreateOptions{ + Address: net.JoinHostPort(ip, strconv.Itoa(port)), + Label: fmt.Sprintf("cp-%s-%d", ip, port), + Mode: linodego.ModeAccept, + Weight: 100, + }) + } + return nodes +} + +func linodeCreateRebuildNodeOptions(nodes []linodego.NodeBalancerNodeCreateOptions) []linodego.NodeBalancerConfigRebuildNodeOptions { + out := make([]linodego.NodeBalancerConfigRebuildNodeOptions, 0, len(nodes)) + for _, n := range nodes { + out = append(out, linodego.NodeBalancerConfigRebuildNodeOptions{NodeBalancerNodeCreateOptions: n}) + } + return out +} + +func selectPrivateIPv4(ips []*net.IP) string { + return linode.SelectIPv4(ips, true) +} diff --git a/upup/pkg/fi/cloudup/linodetasks/loadbalancer_backends.go b/upup/pkg/fi/cloudup/linodetasks/loadbalancer_backends.go new file mode 100644 index 0000000000000..5ef7c0438918f --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/loadbalancer_backends.go @@ -0,0 +1,83 @@ +/* +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 linodetasks + +import ( + "fmt" + + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +// +kops:fitask +type LoadBalancerBackends struct { + Name *string + Lifecycle fi.Lifecycle + + LoadBalancer *LoadBalancer +} + +var _ fi.CloudupTask = &LoadBalancerBackends{} + +func (l *LoadBalancerBackends) Run(c *fi.CloudupContext) error { + return fi.CloudupDefaultDeltaRunMethod(l, c) +} + +func (l *LoadBalancerBackends) Find(c *fi.CloudupContext) (*LoadBalancerBackends, error) { + cloud := c.T.Cloud.(linode.LinodeCloud) + + nodebalancerID := fi.ValueOf(l.LoadBalancer.ID) + if nodebalancerID == 0 { + return nil, nil + } + + configs, err := cloud.Client().ListNodeBalancerConfigs(c.Context(), nodebalancerID, nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) NodeBalancer configs: %w", err) + } + if len(configs) == 0 { + return nil, nil + } + + return &LoadBalancerBackends{ + Name: l.Name, + Lifecycle: l.Lifecycle, + LoadBalancer: l.LoadBalancer, + }, nil +} + +func (_ *LoadBalancerBackends) CheckChanges(actual, expected, changes *LoadBalancerBackends) error { + return nil +} + +func (_ *LoadBalancerBackends) RenderLinode(t *linode.APITarget, actual, expected, changes *LoadBalancerBackends) error { + nodebalancerID := fi.ValueOf(expected.LoadBalancer.ID) + if nodebalancerID == 0 { + return fi.NewTryAgainLaterError("waiting for NodeBalancer to be created") + } + + lbName := fi.ValueOf(expected.LoadBalancer.Name) + backends, err := linodeDiscoverControlPlaneBackends(t.Cloud.Client(), expected.LoadBalancer.Tags) + if err != nil { + return err + } + if len(backends) == 0 { + return fi.NewTryAgainLaterError("waiting for backend instances to be ready") + } + + return ensureLoadBalancerConfigs(t.Cloud.Client(), nodebalancerID, lbName, backends) +} diff --git a/upup/pkg/fi/cloudup/linodetasks/loadbalancer_fitask.go b/upup/pkg/fi/cloudup/linodetasks/loadbalancer_fitask.go new file mode 100644 index 0000000000000..0fffd0f8391cc --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/loadbalancer_fitask.go @@ -0,0 +1,52 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 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. +*/ + +// Code generated by fitask. DO NOT EDIT. + +package linodetasks + +import ( + "k8s.io/kops/upup/pkg/fi" +) + +// LoadBalancer + +var _ fi.HasLifecycle = &LoadBalancer{} + +// GetLifecycle returns the Lifecycle of the object, implementing fi.HasLifecycle +func (o *LoadBalancer) GetLifecycle() fi.Lifecycle { + return o.Lifecycle +} + +// SetLifecycle sets the Lifecycle of the object, implementing fi.SetLifecycle +func (o *LoadBalancer) SetLifecycle(lifecycle fi.Lifecycle) { + o.Lifecycle = lifecycle +} + +var _ fi.HasName = &LoadBalancer{} + +// GetName returns the Name of the object, implementing fi.HasName +func (o *LoadBalancer) GetName() *string { + return o.Name +} + +// String is the stringer function for the task, producing readable output using fi.TaskAsString +func (o *LoadBalancer) String() string { + return fi.CloudupTaskAsString(o) +} diff --git a/upup/pkg/fi/cloudup/linodetasks/loadbalancer_test.go b/upup/pkg/fi/cloudup/linodetasks/loadbalancer_test.go new file mode 100644 index 0000000000000..20298cbf9b482 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/loadbalancer_test.go @@ -0,0 +1,302 @@ +/* +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 linodetasks + +import ( + "context" + "net" + "slices" + "testing" + + "github.com/linode/linodego" + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/wellknownports" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +func newTestCloudupContext(t *testing.T, cloud linode.LinodeCloud) *fi.CloudupContext { + t.Helper() + + ctx, err := fi.NewCloudupContext( + context.Background(), + fi.DeletionProcessingModeDeleteIncludingDeferred, + linode.NewAPITarget(cloud), + nil, + cloud, + nil, + nil, + nil, + nil, + ) + if err != nil { + t.Fatalf("unexpected error creating context: %v", err) + } + + return ctx +} + +func TestNormalizedLoadBalancerLabel(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "dots replaced", + input: "api.kops-test.linode.k8s.local", + want: "api-kops-test-linode-k8s-local", + }, + { + name: "invalid chars removed", + input: "api@@@###", + want: "api", + }, + { + name: "empty fallback", + input: "...", + want: "kops-api", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := linode.NormalizedLoadBalancerLabel(tt.input); got != tt.want { + t.Fatalf("unexpected normalized label: got %q, want %q", got, tt.want) + } + }) + } +} + +func TestLoadBalancerRenderLinodeCreateWithoutBackends(t *testing.T) { + privateIP := net.ParseIP("192.168.210.226") + client := &linode.MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{ + { + ID: 101, + Tags: []string{kops.LabelClusterName + ":kops-test.linode.k8s.local", linode.TagKubernetesInstanceRole + ":ControlPlane"}, + IPv4: []*net.IP{&privateIP}, + }, + }, + } + target := linode.NewAPITarget(&linode.MockLinodeCloud{Client_: client}) + + expected := &LoadBalancer{ + Name: fi.PtrTo("api.kops-test.linode.k8s.local"), + Region: fi.PtrTo("us-east"), + Tags: []string{ + kops.LabelClusterName + ":kops-test.linode.k8s.local", + linode.TagKubernetesInstanceRole + ":ControlPlane", + }, + } + + if err := (&LoadBalancer{}).RenderLinode(target, nil, expected, nil); err != nil { + t.Fatalf("RenderLinode returned error: %v", err) + } + + if got, want := client.CreateNodeBalancerCalls, 1; got != want { + t.Fatalf("unexpected create calls: got %d, want %d", got, want) + } + + if got, want := fi.ValueOf(client.LastCreateNodeBalancerOpts.Label), "api-kops-test-linode-k8s-local"; got != want { + t.Fatalf("unexpected nodebalancer label: got %q, want %q", got, want) + } + + if got := len(client.LastCreateNodeBalancerOpts.Configs); got != 0 { + t.Fatalf("expected no configs when no backends are discovered, got %d", got) + } + if got, want := client.CreateNodeBalancerConfigCalls, 2; got != want { + t.Fatalf("expected configs to be reconciled after create: got %d, want %d", got, want) + } +} + +func TestLoadBalancerFindMatchesNormalizedLabel(t *testing.T) { + client := &linode.MockLinodeClient{ + ListNodeBalancersResponse: []linodego.NodeBalancer{ + {ID: 7, Label: fi.PtrTo("api-kops-test-linode-k8s-local"), Region: "us-east"}, + }, + } + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &LoadBalancer{Name: fi.PtrTo("api.kops-test.linode.k8s.local")} + actual, err := task.Find(ctx) + if err != nil { + t.Fatalf("Find returned error: %v", err) + } + if actual == nil { + t.Fatalf("expected to find nodebalancer by normalized label") + } + if got, want := fi.ValueOf(actual.ID), 7; got != want { + t.Fatalf("unexpected nodebalancer id: got %d, want %d", got, want) + } + if got, want := fi.ValueOf(actual.Region), "us-east"; got != want { + t.Fatalf("unexpected nodebalancer region: got %q, want %q", got, want) + } + if got, want := fi.ValueOf(task.ID), 7; got != want { + t.Fatalf("task ID should be propagated after Find: got %d, want %d", got, want) + } +} + +func TestLinodeDiscoverControlPlaneBackendsPublicFallback(t *testing.T) { + publicIP := net.ParseIP("203.0.113.10") + client := &linode.MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{ + { + ID: 101, + Tags: []string{kops.LabelClusterName + ":kops-test.linode.k8s.local", linode.TagKubernetesInstanceRole + ":ControlPlane"}, + IPv4: []*net.IP{&publicIP}, + }, + }, + } + + backends, err := linodeDiscoverControlPlaneBackends(client, []string{kops.LabelClusterName + ":kops-test.linode.k8s.local"}) + if err != nil { + t.Fatalf("linodeDiscoverControlPlaneBackends returned error: %v", err) + } + if got, want := len(backends), 1; got != want { + t.Fatalf("unexpected backend count: got %d, want %d", got, want) + } + if got, want := backends[0], "203.0.113.10"; got != want { + t.Fatalf("unexpected backend IP: got %q, want %q", got, want) + } +} + +func TestEnsureLoadBalancerConfigsCreatesMissingPorts(t *testing.T) { + client := &linode.MockLinodeClient{} + backends := []string{"192.168.210.226"} + + err := ensureLoadBalancerConfigs(client, 2085634, "api.kops-test.linode.k8s.local", backends) + if err != nil { + t.Fatalf("ensureLoadBalancerConfigs returned error: %v", err) + } + + if got, want := client.CreateNodeBalancerConfigCalls, 2; got != want { + t.Fatalf("unexpected config create calls: got %d, want %d", got, want) + } + if got, want := client.CreateNodeBalancerNodeCalls, 2; got != want { + t.Fatalf("unexpected node create calls: got %d, want %d", got, want) + } + + ports := []int{client.CreateNodeBalancerConfigOpts[0].Port, client.CreateNodeBalancerConfigOpts[1].Port} + slices.Sort(ports) + if got, want := ports, []int{wellknownports.KubeAPIServer, wellknownports.KopsControllerPort}; !slices.Equal(got, want) { + t.Fatalf("unexpected created ports: got %v, want %v", got, want) + } +} + +func TestEnsureLoadBalancerConfigsRebuildsExistingPorts(t *testing.T) { + client := &linode.MockLinodeClient{ + ListNodeBalancerConfigsResponse: []linodego.NodeBalancerConfig{ + {ID: 11, Port: wellknownports.KubeAPIServer}, + {ID: 12, Port: wellknownports.KopsControllerPort}, + }, + } + backends := []string{"192.168.210.226"} + + err := ensureLoadBalancerConfigs(client, 2085634, "api.kops-test.linode.k8s.local", backends) + if err != nil { + t.Fatalf("ensureLoadBalancerConfigs returned error: %v", err) + } + + if got, want := client.RebuildNodeBalancerConfigCalls, 2; got != want { + t.Fatalf("unexpected config rebuild calls: got %d, want %d", got, want) + } + if got, want := client.CreateNodeBalancerNodeCalls, 2; got != want { + t.Fatalf("expected missing nodes to be created during rebuild path: got %d, want %d", got, want) + } + if got := client.CreateNodeBalancerConfigCalls; got != 0 { + t.Fatalf("did not expect create calls when configs already exist, got %d", got) + } +} + +func TestExtractClusterTag(t *testing.T) { + tags := []string{"foo:bar", kops.LabelClusterName + ":kops-test.linode.k8s.local"} + if got, want := extractClusterTag(tags), kops.LabelClusterName+":kops-test.linode.k8s.local"; got != want { + t.Fatalf("unexpected cluster tag: got %q, want %q", got, want) + } +} + +func TestLoadBalancerBackendsRenderLinodeWithoutLoadBalancerDoesNotBlock(t *testing.T) { + cloud := &linode.MockLinodeCloud{Client_: &linode.MockLinodeClient{}} + target := linode.NewAPITarget(cloud) + + expected := &LoadBalancerBackends{ + Name: fi.PtrTo("backends.api.kops-test.linode.k8s.local"), + LoadBalancer: &LoadBalancer{ + Name: fi.PtrTo("api.kops-test.linode.k8s.local"), + Tags: []string{kops.LabelClusterName + ":kops-test.linode.k8s.local"}, + }, + } + + err := (&LoadBalancerBackends{}).RenderLinode(target, nil, expected, nil) + if _, ok := err.(*fi.TryAgainLaterError); !ok { + t.Fatalf("expected TryAgainLaterError when LB not ready, got %v", err) + } +} + +func TestLoadBalancerBackendsRenderLinodeWithoutBackendsDoesNotBlock(t *testing.T) { + client := &linode.MockLinodeClient{} + cloud := &linode.MockLinodeCloud{Client_: client} + target := linode.NewAPITarget(cloud) + + expected := &LoadBalancerBackends{ + Name: fi.PtrTo("backends.api.kops-test.linode.k8s.local"), + LoadBalancer: &LoadBalancer{ + Name: fi.PtrTo("api.kops-test.linode.k8s.local"), + ID: fi.PtrTo(1), + Tags: []string{kops.LabelClusterName + ":kops-test.linode.k8s.local"}, + }, + } + + err := (&LoadBalancerBackends{}).RenderLinode(target, nil, expected, nil) + if _, ok := err.(*fi.TryAgainLaterError); !ok { + t.Fatalf("expected TryAgainLaterError when backends not ready, got %v", err) + } +} + +func TestLoadBalancerBackendsRenderLinodeReconcilesWhenBackendsReady(t *testing.T) { + privateIP := net.ParseIP("192.168.210.226") + client := &linode.MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{ + { + ID: 101, + Tags: []string{kops.LabelClusterName + ":kops-test.linode.k8s.local", linode.TagKubernetesInstanceRole + ":ControlPlane"}, + IPv4: []*net.IP{&privateIP}, + }, + }, + } + cloud := &linode.MockLinodeCloud{Client_: client} + target := linode.NewAPITarget(cloud) + + expected := &LoadBalancerBackends{ + Name: fi.PtrTo("backends.api.kops-test.linode.k8s.local"), + LoadBalancer: &LoadBalancer{ + Name: fi.PtrTo("api.kops-test.linode.k8s.local"), + ID: fi.PtrTo(1), + Tags: []string{kops.LabelClusterName + ":kops-test.linode.k8s.local"}, + }, + } + + if err := (&LoadBalancerBackends{}).RenderLinode(target, nil, expected, nil); err != nil { + t.Fatalf("unexpected error reconciling backends: %v", err) + } + + if got, want := client.CreateNodeBalancerConfigCalls, 2; got != want { + t.Fatalf("expected backend reconcile to create both configs: got %d, want %d", got, want) + } +} diff --git a/upup/pkg/fi/cloudup/linodetasks/loadbalancerbackends_fitask.go b/upup/pkg/fi/cloudup/linodetasks/loadbalancerbackends_fitask.go new file mode 100644 index 0000000000000..5d7c6d40ed03d --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/loadbalancerbackends_fitask.go @@ -0,0 +1,52 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 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. +*/ + +// Code generated by fitask. DO NOT EDIT. + +package linodetasks + +import ( + "k8s.io/kops/upup/pkg/fi" +) + +// LoadBalancerBackends + +var _ fi.HasLifecycle = &LoadBalancerBackends{} + +// GetLifecycle returns the Lifecycle of the object, implementing fi.HasLifecycle +func (o *LoadBalancerBackends) GetLifecycle() fi.Lifecycle { + return o.Lifecycle +} + +// SetLifecycle sets the Lifecycle of the object, implementing fi.SetLifecycle +func (o *LoadBalancerBackends) SetLifecycle(lifecycle fi.Lifecycle) { + o.Lifecycle = lifecycle +} + +var _ fi.HasName = &LoadBalancerBackends{} + +// GetName returns the Name of the object, implementing fi.HasName +func (o *LoadBalancerBackends) GetName() *string { + return o.Name +} + +// String is the stringer function for the task, producing readable output using fi.TaskAsString +func (o *LoadBalancerBackends) String() string { + return fi.CloudupTaskAsString(o) +}