diff --git a/.gitignore b/.gitignore index b5a29c398b535..699f5599e4dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,6 @@ _rundir # kustomize helm chart cache charts/ + +# Used by asdf (https://asdf-vm.com/), ignore local file directing which version of a tool to use +.tool-versions diff --git a/dns-controller/cmd/dns-controller/main.go b/dns-controller/cmd/dns-controller/main.go index 9cd6f8e30f1f5..d29544ed10a1b 100644 --- a/dns-controller/cmd/dns-controller/main.go +++ b/dns-controller/cmd/dns-controller/main.go @@ -37,6 +37,7 @@ import ( "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/aws/route53" _ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/do" _ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/google/clouddns" + _ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/linode" _ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/openstack/designate" _ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/scaleway" "k8s.io/kops/pkg/wellknownports" diff --git a/dnsprovider/pkg/dnsprovider/providers/linode/dns.go b/dnsprovider/pkg/dnsprovider/providers/linode/dns.go new file mode 100644 index 0000000000000..d21c5c8f0e2a7 --- /dev/null +++ b/dnsprovider/pkg/dnsprovider/providers/linode/dns.go @@ -0,0 +1,421 @@ +/* +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" + "io" + "os" + "strings" + "time" + + "github.com/linode/linodego" + "k8s.io/klog/v2" + + "k8s.io/kops/dns-controller/pkg/dns" + "k8s.io/kops/dnsprovider/pkg/dnsprovider" + "k8s.io/kops/dnsprovider/pkg/dnsprovider/rrstype" +) + +var _ dnsprovider.Interface = (*Interface)(nil) + +const ( + ProviderName = "linode" + apiTimeout = 30 * time.Second +) + +func init() { + dnsprovider.RegisterDNSProvider(ProviderName, func(config io.Reader) (dnsprovider.Interface, error) { + client, err := newClient() + if err != nil { + return nil, err + } + return NewProvider(client), nil + }) +} + +func newClient() (*linodego.Client, error) { + apiToken := os.Getenv("LINODE_TOKEN") + if apiToken == "" { + return nil, fmt.Errorf("LINODE_TOKEN environment variable is required") + } + + client := linodego.NewClient(nil) + client.SetUserAgent("kops/dns-controller") + client.SetToken(apiToken) + return &client, nil +} + +// Interface implements dnsprovider.Interface for Linode (Akamai) DNS +type Interface struct { + client *linodego.Client +} + +// NewProvider returns a Linode (Akamai) DNS provider +func NewProvider(client *linodego.Client) dnsprovider.Interface { + return &Interface{ + client: client, + } +} + +func (i *Interface) Zones() (dnsprovider.Zones, bool) { + return &zones{ + client: i.client, + }, true +} + +// zones implements dnsprovider.Zones +type zones struct { + client *linodego.Client +} + +func (z *zones) List() ([]dnsprovider.Zone, error) { + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + domains, err := z.client.ListDomains(ctx, &linodego.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) domains: %w", err) + } + klog.V(2).Infof("Found %d Linode (Akamai) domains", len(domains)) + + var zoneList []dnsprovider.Zone + for _, domain := range domains { + zoneList = append(zoneList, &zone{ + name: domain.Domain, + id: domain.Domain, + domainID: domain.ID, + client: z.client, + }) + } + + return zoneList, nil +} + +func (z *zones) Add(newZone dnsprovider.Zone) (dnsprovider.Zone, error) { + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + domain, err := z.client.CreateDomain(ctx, linodego.DomainCreateOptions{ + Domain: newZone.Name(), + Type: linodego.DomainTypeMaster, + }) + if err != nil { + return nil, fmt.Errorf("error creating domain: %w", err) + } + + return &zone{ + name: domain.Domain, + id: domain.Domain, + domainID: domain.ID, + client: z.client, + }, nil +} + +func (z *zones) Remove(zn dnsprovider.Zone) error { + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + linodeZone, ok := zn.(*zone) + if !ok { + return fmt.Errorf("unexpected zone type %T", zn) + } + domainID, err := linodeZone.getDomainID(ctx) + if err != nil { + return err + } + return z.client.DeleteDomain(ctx, domainID) +} + +func (z *zones) New(name string) (dnsprovider.Zone, error) { + return &zone{ + name: name, + id: name, + client: z.client, + }, nil +} + +// zone implements dnsprovider.Zone +type zone struct { + name string + id string // domain name; satisfies dnsprovider.Zone.ID() + domainID int // Linode integer domain ID, cached after first lookup + client *linodego.Client +} + +// getDomainID returns the integer Linode domain ID, fetching from the API if not already cached. +func (z *zone) getDomainID(ctx context.Context) (int, error) { + if z.domainID != 0 { + return z.domainID, nil + } + domains, err := z.client.ListDomains(ctx, &linodego.ListOptions{}) + if err != nil { + return 0, fmt.Errorf("error listing Linode (Akamai) domains: %w", err) + } + for _, domain := range domains { + if domain.Domain == z.name { + z.domainID = domain.ID + return z.domainID, nil + } + } + return 0, fmt.Errorf("domain %s not found", z.name) +} + +func (z *zone) Name() string { + return z.name +} + +func (z *zone) ID() string { + return z.id +} + +func (z *zone) ResourceRecordSets() (dnsprovider.ResourceRecordSets, bool) { + return &resourceRecordSets{ + zone: z, + client: z.client, + }, true +} + +// resourceRecordSets implements dnsprovider.ResourceRecordSets +type resourceRecordSets struct { + zone *zone + client *linodego.Client +} + +func (r *resourceRecordSets) List() ([]dnsprovider.ResourceRecordSet, error) { + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + domainID, err := r.zone.getDomainID(ctx) + if err != nil { + return nil, err + } + + records, err := r.client.ListDomainRecords(ctx, domainID, &linodego.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) domain records for %s: %w", r.zone.Name(), err) + } + + // Group records by name+type to coalesce multi-value sets + rrsetMap := make(map[string]*resourceRecordSet) + for _, record := range records { + fullName := dns.EnsureDotSuffix(record.Name) + dns.EnsureDotSuffix(r.zone.Name()) + key := fullName + "|" + string(record.Type) + if rrset, exists := rrsetMap[key]; exists { + rrset.data = append(rrset.data, record.Target) + } else { + rrsetMap[key] = &resourceRecordSet{ + name: fullName, + data: []string{record.Target}, + ttl: int64(record.TTLSec), + recordType: rrstype.RrsType(record.Type), + } + } + } + + var rrsets []dnsprovider.ResourceRecordSet + for _, rrset := range rrsetMap { + rrsets = append(rrsets, rrset) + } + return rrsets, nil +} + +func (r *resourceRecordSets) Get(name string) ([]dnsprovider.ResourceRecordSet, error) { + records, err := r.List() + if err != nil { + return nil, err + } + + var matches []dnsprovider.ResourceRecordSet + for _, record := range records { + if record.Name() == name { + matches = append(matches, record) + } + } + return matches, nil +} + +func (r *resourceRecordSets) New(name string, rrdatas []string, ttl int64, rrtype rrstype.RrsType) dnsprovider.ResourceRecordSet { + if len(rrdatas) == 0 { + return nil + } + return &resourceRecordSet{ + name: name, + data: rrdatas, + ttl: ttl, + recordType: rrtype, + } +} + +func (r *resourceRecordSets) StartChangeset() dnsprovider.ResourceRecordChangeset { + return &resourceRecordChangeset{ + client: r.client, + zone: r.zone, + rrsets: r, + } +} + +func (r *resourceRecordSets) Zone() dnsprovider.Zone { + return r.zone +} + +// resourceRecordSet implements dnsprovider.ResourceRecordSet +type resourceRecordSet struct { + name string + data []string + ttl int64 + recordType rrstype.RrsType +} + +func (r *resourceRecordSet) Name() string { + return r.name +} + +func (r *resourceRecordSet) Rrdatas() []string { + return r.data +} + +func (r *resourceRecordSet) Ttl() int64 { + return r.ttl +} + +func (r *resourceRecordSet) Type() rrstype.RrsType { + return r.recordType +} + +// resourceRecordChangeset implements dnsprovider.ResourceRecordChangeset +type resourceRecordChangeset struct { + client *linodego.Client + zone *zone + rrsets dnsprovider.ResourceRecordSets + + additions []dnsprovider.ResourceRecordSet + removals []dnsprovider.ResourceRecordSet + upserts []dnsprovider.ResourceRecordSet +} + +func (r *resourceRecordChangeset) ResourceRecordSets() dnsprovider.ResourceRecordSets { + return r.rrsets +} + +func (r *resourceRecordChangeset) Add(rrset dnsprovider.ResourceRecordSet) dnsprovider.ResourceRecordChangeset { + r.additions = append(r.additions, rrset) + return r +} + +func (r *resourceRecordChangeset) Remove(rrset dnsprovider.ResourceRecordSet) dnsprovider.ResourceRecordChangeset { + r.removals = append(r.removals, rrset) + return r +} + +func (r *resourceRecordChangeset) Upsert(rrset dnsprovider.ResourceRecordSet) dnsprovider.ResourceRecordChangeset { + r.upserts = append(r.upserts, rrset) + return r +} + +func (r *resourceRecordChangeset) Apply(ctx context.Context) error { + if r.IsEmpty() { + klog.V(4).Info("record change set is empty") + return nil + } + + klog.V(2).Info("applying changes in record change set") + + domainID, err := r.zone.getDomainID(ctx) + if err != nil { + return err + } + + // Fetch existing records once for all deletions (removals + upsert pre-delete). + var existingRecords []linodego.DomainRecord + if len(r.removals) > 0 || len(r.upserts) > 0 { + existingRecords, err = r.client.ListDomainRecords(ctx, domainID, &linodego.ListOptions{}) + if err != nil { + return fmt.Errorf("error listing domain records: %w", err) + } + } + + for _, rrset := range r.removals { + if err := r.deleteRecord(ctx, domainID, rrset, existingRecords); err != nil { + return fmt.Errorf("error removing record %s: %w", rrset.Name(), err) + } + } + if len(r.removals) > 0 { + klog.V(2).Info("record change set removals complete") + } + + for _, rrset := range r.additions { + if err := r.createRecord(ctx, domainID, rrset); err != nil { + return fmt.Errorf("error adding record %s: %w", rrset.Name(), err) + } + } + if len(r.additions) > 0 { + klog.V(2).Info("record change set additions complete") + } + + for _, rrset := range r.upserts { + if err := r.deleteRecord(ctx, domainID, rrset, existingRecords); err != nil { + klog.V(2).Infof("error deleting existing record %s before upsert (may not exist): %v", rrset.Name(), err) + } + if err := r.createRecord(ctx, domainID, rrset); err != nil { + return fmt.Errorf("error upserting record %s: %w", rrset.Name(), err) + } + } + if len(r.upserts) > 0 { + klog.V(2).Info("record change set upserts complete") + } + + return nil +} + +func (r *resourceRecordChangeset) IsEmpty() bool { + return len(r.additions) == 0 && len(r.removals) == 0 && len(r.upserts) == 0 +} + +func (r *resourceRecordChangeset) createRecord(ctx context.Context, domainID int, rrset dnsprovider.ResourceRecordSet) error { + recordName := strings.TrimSuffix(rrset.Name(), ".") + recordName = strings.TrimSuffix(recordName, "."+r.zone.Name()) + + for _, data := range rrset.Rrdatas() { + _, err := r.client.CreateDomainRecord(ctx, domainID, linodego.DomainRecordCreateOptions{ + Type: linodego.DomainRecordType(rrset.Type()), + Name: recordName, + Target: data, + TTLSec: int(rrset.Ttl()), + }) + if err != nil { + return err + } + } + return nil +} + +// deleteRecord deletes all records matching the rrset's name and type from the pre-fetched existingRecords list. +func (r *resourceRecordChangeset) deleteRecord(ctx context.Context, domainID int, rrset dnsprovider.ResourceRecordSet, existingRecords []linodego.DomainRecord) error { + recordName := strings.TrimSuffix(rrset.Name(), ".") + recordName = strings.TrimSuffix(recordName, "."+r.zone.Name()) + + for _, record := range existingRecords { + if record.Name == recordName && string(record.Type) == string(rrset.Type()) { + if err := r.client.DeleteDomainRecord(ctx, domainID, record.ID); err != nil { + return err + } + } + } + return nil +} diff --git a/dnsprovider/pkg/dnsprovider/providers/linode/dns_test.go b/dnsprovider/pkg/dnsprovider/providers/linode/dns_test.go new file mode 100644 index 0000000000000..999f4609a4628 --- /dev/null +++ b/dnsprovider/pkg/dnsprovider/providers/linode/dns_test.go @@ -0,0 +1,139 @@ +/* +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 ( + "testing" + + "github.com/linode/linodego" +) + +func TestNewProvider(t *testing.T) { + client := linodego.NewClient(nil) + provider := NewProvider(&client) + if provider == nil { + t.Fatal("NewProvider returned nil") + } + + zones, ok := provider.Zones() + if !ok { + t.Fatal("Provider should support zones") + } + if zones == nil { + t.Fatal("Zones should not be nil") + } +} + +func TestZoneNew(t *testing.T) { + client := linodego.NewClient(nil) + z := &zones{client: &client} + + zone, err := z.New("example.com") + if err != nil { + t.Fatalf("Failed to create new zone: %v", err) + } + if zone == nil { + t.Fatal("New zone should not be nil") + } + if zone.Name() != "example.com" { + t.Errorf("Expected zone name 'example.com', got '%s'", zone.Name()) + } + if zone.ID() != "example.com" { + t.Errorf("Expected zone ID 'example.com', got '%s'", zone.ID()) + } +} + +func TestResourceRecordSets(t *testing.T) { + client := linodego.NewClient(nil) + z := &zone{ + name: "example.com", + id: "example.com", + client: &client, + } + + rrsets, ok := z.ResourceRecordSets() + if !ok { + t.Fatal("Zone should support resource record sets") + } + if rrsets == nil { + t.Fatal("ResourceRecordSets should not be nil") + } + + // Test creating a new record set + rrs := rrsets.New("test.example.com", []string{"192.0.2.1"}, 300, "A") + if rrs == nil { + t.Fatal("New ResourceRecordSet should not be nil") + } + if rrs.Name() != "test.example.com" { + t.Errorf("Expected name 'test.example.com', got '%s'", rrs.Name()) + } + if rrs.Ttl() != 300 { + t.Errorf("Expected TTL 300, got %d", rrs.Ttl()) + } + if string(rrs.Type()) != "A" { + t.Errorf("Expected type 'A', got '%s'", rrs.Type()) + } + + // Test empty data returns nil + nilRrs := rrsets.New("test.example.com", []string{}, 300, "A") + if nilRrs != nil { + t.Error("ResourceRecordSet with empty data should return nil") + } +} + +func TestChangeset(t *testing.T) { + client := linodego.NewClient(nil) + z := &zone{ + name: "example.com", + id: "example.com", + client: &client, + } + rrsets := &resourceRecordSets{ + zone: z, + client: &client, + } + + changeset := rrsets.StartChangeset() + if changeset == nil { + t.Fatal("StartChangeset should not return nil") + } + + if !changeset.IsEmpty() { + t.Error("New changeset should be empty") + } + + // Test adding records + rrs := rrsets.New("test.example.com", []string{"192.0.2.1"}, 300, "A") + changeset.Add(rrs) + if changeset.IsEmpty() { + t.Error("Changeset with additions should not be empty") + } + + // Test removals + changeset2 := rrsets.StartChangeset() + changeset2.Remove(rrs) + if changeset2.IsEmpty() { + t.Error("Changeset with removals should not be empty") + } + + // Test upserts + changeset3 := rrsets.StartChangeset() + changeset3.Upsert(rrs) + if changeset3.IsEmpty() { + t.Error("Changeset with upserts should not be empty") + } +} diff --git a/pkg/model/components/etcdmanager/model.go b/pkg/model/components/etcdmanager/model.go index 5f79323db1f82..046da748b96a4 100644 --- a/pkg/model/components/etcdmanager/model.go +++ b/pkg/model/components/etcdmanager/model.go @@ -41,6 +41,7 @@ import ( "k8s.io/kops/upup/pkg/fi/cloudup/do" "k8s.io/kops/upup/pkg/fi/cloudup/gce" "k8s.io/kops/upup/pkg/fi/cloudup/hetzner" + cloudlinode "k8s.io/kops/upup/pkg/fi/cloudup/linode" "k8s.io/kops/upup/pkg/fi/cloudup/openstack" "k8s.io/kops/upup/pkg/fi/cloudup/scaleway" "k8s.io/kops/upup/pkg/fi/fitasks" @@ -559,6 +560,16 @@ func (b *EtcdManagerBuilder) buildPod(etcdCluster kops.EtcdClusterSpec, instance } config.VolumeNameTag = fmt.Sprintf("%s=%s", scaleway.TagInstanceGroup, instanceGroupName) + case kops.CloudProviderLinode: + config.VolumeProvider = "linode" + + config.VolumeTag = []string{ + cloudlinode.BuildLinodeTag(kops.LabelClusterName, b.Cluster.Name), + cloudlinode.BuildLinodeTag(cloudlinode.TagEtcdClusterName, etcdCluster.Name), + cloudlinode.BuildLinodeTag(cloudlinode.TagKubernetesInstanceRole, string(kops.InstanceGroupRoleControlPlane)), + } + config.VolumeNameTag = cloudlinode.BuildLinodeTag(cloudlinode.TagKubernetesInstanceGroup, instanceGroupName) + case kops.CloudProviderMetal: config.VolumeProvider = "external" config.BackupStore = "file:///mnt/disks/backups" @@ -622,6 +633,13 @@ func (b *EtcdManagerBuilder) buildPod(etcdCluster kops.EtcdClusterSpec, instance envMap := env.BuildSystemComponentEnvVars(&b.Cluster.Spec) container.Env = envMap.ToEnvVars() + if b.Cluster.GetCloudProvider() == kops.CloudProviderLinode { + // Linode (Akamai) Object Storage requires checksum behavior compatible with AWS SDK "when_required" mode. + container.Env = append(container.Env, + v1.EnvVar{Name: "AWS_REQUEST_CHECKSUM_CALCULATION", Value: "when_required"}, + v1.EnvVar{Name: "AWS_RESPONSE_CHECKSUM_VALIDATION", Value: "when_required"}, + ) + } if etcdCluster.Manager != nil { if etcdCluster.Manager.BackupRetentionDays != nil { diff --git a/pkg/model/components/etcdmanager/model_test.go b/pkg/model/components/etcdmanager/model_test.go index b01da42c0944f..f486f1ed10641 100644 --- a/pkg/model/components/etcdmanager/model_test.go +++ b/pkg/model/components/etcdmanager/model_test.go @@ -19,8 +19,10 @@ package etcdmanager import ( "fmt" "path/filepath" + "strings" "testing" + "k8s.io/kops/pkg/apis/kops" "k8s.io/kops/pkg/assets" "k8s.io/kops/pkg/featureflag" "k8s.io/kops/pkg/model" @@ -66,6 +68,70 @@ func Test_RunEtcdManagerBuilder(t *testing.T) { } } +func Test_BuildPod_LinodeUsesNativeBackupStoreAndVolumeProvider(t *testing.T) { + featureflag.ParseFlags("-ImageDigest") + + kopsModelContext, err := LoadKopsModelContext("tests/minimal") + if err != nil { + t.Fatalf("error loading model: %v", err) + } + + kopsModelContext.Cluster.Spec.CloudProvider = kops.CloudProviderSpec{ + Linode: &kops.LinodeSpec{}, + } + kopsModelContext.Cluster.Spec.EtcdClusters[0].Backups = &kops.EtcdBackupSpec{ + BackupStore: "linode://kops-test/minimal.example.com/backups/etcd/main", + } + + builder := EtcdManagerBuilder{ + KopsModelContext: kopsModelContext, + AssetBuilder: assets.NewAssetBuilder(vfs.Context, kopsModelContext.Cluster.Spec.Assets, false), + } + + pod, err := builder.buildPod(kopsModelContext.Cluster.Spec.EtcdClusters[0], "master-us-test-1a") + if err != nil { + t.Fatalf("buildPod returned error: %v", err) + } + + if len(pod.Spec.Containers) != 1 { + t.Fatalf("expected exactly one container, got %d", len(pod.Spec.Containers)) + } + + command := strings.Join(pod.Spec.Containers[0].Command, " ") + wantArgs := []string{ + "--backup-store=linode://kops-test/minimal.example.com/backups/etcd/main", + "--volume-provider=linode", + "--volume-name-tag=kops.k8s.io/instance-group:master-us-test-1a", + "--volume-tag=kops.k8s.io/cluster:minimal.example.com", + "--volume-tag=kops.k8s.io/etcd:main", + "--volume-tag=kops.k8s.io/instance-role:ControlPlane", + } + + for _, want := range wantArgs { + if !strings.Contains(command, want) { + t.Fatalf("expected etcd-manager command to contain %q, got: %s", want, command) + } + } + + for _, notWant := range []string{"--data-dir=/var/lib/etcd-manager/main", "--backup-store=s3://", "--backup-store=do://"} { + if strings.Contains(command, notWant) { + t.Fatalf("expected etcd-manager command to not contain %q, got: %s", notWant, command) + } + } + + envByName := map[string]string{} + for _, envVar := range pod.Spec.Containers[0].Env { + envByName[envVar.Name] = envVar.Value + } + + if envByName["AWS_REQUEST_CHECKSUM_CALCULATION"] != "when_required" { + t.Fatalf("expected AWS_REQUEST_CHECKSUM_CALCULATION=when_required, got %q", envByName["AWS_REQUEST_CHECKSUM_CALCULATION"]) + } + if envByName["AWS_RESPONSE_CHECKSUM_VALIDATION"] != "when_required" { + t.Fatalf("expected AWS_RESPONSE_CHECKSUM_VALIDATION=when_required, got %q", envByName["AWS_RESPONSE_CHECKSUM_VALIDATION"]) + } +} + func LoadKopsModelContext(basedir string) (*model.KopsModelContext, error) { spec, err := testutils.LoadModel(basedir) if err != nil { diff --git a/pkg/model/context.go b/pkg/model/context.go index aade9c9e055eb..b67725d0edb7d 100644 --- a/pkg/model/context.go +++ b/pkg/model/context.go @@ -183,6 +183,13 @@ func (b *KopsModelContext) CloudTagsForInstanceGroup(ig *kops.InstanceGroup) (ma labels[hetzner.TagKubernetesNodeLabelPrefix+k] = v case kops.CloudProviderGCE: // TODO: Do nothing for now while we figure out how to address GCE label length limit of 63 + case kops.CloudProviderLinode: + // Linode (Akamai) tags have a 50 character limit + // Only store the critical kops.k8s.io/instancegroup label + // Role labels will be derived from the instance role tag by the identifier + if k == "kops.k8s.io/instancegroup" { + labels[k] = v + } default: labels[nodeidentityaws.ClusterAutoscalerNodeTemplateLabel+k] = v } 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/pkg/model/linodemodel/dns.go b/pkg/model/linodemodel/dns.go new file mode 100644 index 0000000000000..e8afcf74b2f94 --- /dev/null +++ b/pkg/model/linodemodel/dns.go @@ -0,0 +1,72 @@ +/* +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/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linodetasks" +) + +// DNSModelBuilder builds DNS tasks for the Linode (Akamai) API load balancer. +type DNSModelBuilder struct { + *LinodeModelContext + Lifecycle fi.Lifecycle +} + +var _ fi.CloudupModelBuilder = &DNSModelBuilder{} + +// Build creates DNS tasks for the API load balancer +func (b *DNSModelBuilder) Build(c *fi.CloudupModelBuilderContext) error { + if !b.Cluster.PublishesDNSRecords() { + return nil + } + + if !b.UseLoadBalancerForAPI() { + return nil + } + + lbTask, found := c.Tasks["LoadBalancer/api."+b.ClusterName()] + if !found { + return nil + } + apiLoadBalancer, ok := lbTask.(*linodetasks.LoadBalancer) + if !ok { + return nil + } + + if b.Cluster.Spec.API.PublicName != "" { + c.AddTask(&linodetasks.DNSRecord{ + Name: fi.PtrTo(b.Cluster.Spec.API.PublicName), + ResourceName: fi.PtrTo(b.Cluster.Spec.API.PublicName), + Lifecycle: b.Lifecycle, + RecordType: fi.PtrTo("A"), + Target: apiLoadBalancer, + }) + } + + if b.UseLoadBalancerForInternalAPI() { + c.AddTask(&linodetasks.DNSRecord{ + Name: fi.PtrTo(b.Cluster.APIInternalName()), + ResourceName: fi.PtrTo(b.Cluster.APIInternalName()), + Lifecycle: b.Lifecycle, + RecordType: fi.PtrTo("A"), + Target: apiLoadBalancer, + }) + } + + return nil +} diff --git a/pkg/model/linodemodel/instances.go b/pkg/model/linodemodel/instances.go new file mode 100644 index 0000000000000..432861aa5bbe9 --- /dev/null +++ b/pkg/model/linodemodel/instances.go @@ -0,0 +1,100 @@ +/* +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" + "slices" + + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/model" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" + "k8s.io/kops/upup/pkg/fi/cloudup/linodetasks" +) + +// InstanceModelBuilder configures Linode (Akamai) instances for each instance group. +type InstanceModelBuilder struct { + *LinodeModelContext + + BootstrapScriptBuilder *model.BootstrapScriptBuilder + Lifecycle fi.Lifecycle +} + +var _ fi.CloudupModelBuilder = &InstanceModelBuilder{} + +func (b *InstanceModelBuilder) Build(c *fi.CloudupModelBuilderContext) error { + if b.BootstrapScriptBuilder == nil { + return fmt.Errorf("bootstrap script builder is required") + } + + if len(b.SSHPublicKeys) == 0 { + return fmt.Errorf("SSH public key is required for Linode (Akamai) instances") + } + sshPublicKeyResource := fi.Resource(fi.NewBytesResource(b.SSHPublicKeys[0])) + + for _, ig := range b.InstanceGroups { + subnets, err := b.GatherSubnets(ig) + if err != nil { + return fmt.Errorf("error building Linode (Akamai) instance task for %q: %w", ig.Name, err) + } + if len(subnets) == 0 || subnets[0].Region == "" { + return fmt.Errorf("error building Linode (Akamai) instance task for %q: subnet region is required", ig.Name) + } + + userData, err := b.BootstrapScriptBuilder.ResourceNodeUp(c, ig) + if err != nil { + return fmt.Errorf("error building bootstrap script for %q: %w", ig.Name, err) + } + + name := b.AutoscalingGroupName(ig) + + cloudTags, err := b.CloudTagsForInstanceGroup(ig) + if err != nil { + return fmt.Errorf("error building cloud tags for %q: %w", ig.Name, err) + } + + tagKeys := make([]string, 0, len(cloudTags)) + for k := range cloudTags { + tagKeys = append(tagKeys, k) + } + slices.Sort(tagKeys) + + tags := make([]string, 0, len(cloudTags)+3) + tags = append(tags, linode.BuildLinodeTag(kops.LabelClusterName, b.ClusterName())) + tags = append(tags, linode.BuildLinodeTag(linode.TagKubernetesInstanceGroup, ig.Name)) + tags = append(tags, linode.BuildLinodeTag(linode.TagKubernetesInstanceRole, string(ig.Spec.Role))) + for _, k := range tagKeys { + tags = append(tags, linode.BuildLinodeTag(k, cloudTags[k])) + } + + t := &linodetasks.Instance{ + Name: fi.PtrTo(name), + Lifecycle: b.Lifecycle, + Region: fi.PtrTo(subnets[0].Region), + Type: fi.PtrTo(ig.Spec.MachineType), + Image: fi.PtrTo(ig.Spec.Image), + Count: int(fi.ValueOf(ig.Spec.MinSize)), + Tags: tags, + AuthorizedKey: &sshPublicKeyResource, + UserData: &userData, + } + c.AddTask(t) + } + + return nil +} diff --git a/pkg/model/linodemodel/sshkey.go b/pkg/model/linodemodel/sshkey.go new file mode 100644 index 0000000000000..6ca2a7ac0fb55 --- /dev/null +++ b/pkg/model/linodemodel/sshkey.go @@ -0,0 +1,55 @@ +/* +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/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" + "k8s.io/kops/upup/pkg/fi/cloudup/linodetasks" +) + +// SSHKeyModelBuilder configures profile SSH key resources for Linode (Akamai). +type SSHKeyModelBuilder struct { + *LinodeModelContext + Lifecycle fi.Lifecycle +} + +var _ fi.CloudupModelBuilder = &SSHKeyModelBuilder{} + +func (b *SSHKeyModelBuilder) Build(c *fi.CloudupModelBuilderContext) error { + if len(b.SSHPublicKeys) == 0 { + return nil + } + + name, err := b.SSHKeyName() + if err != nil { + return fmt.Errorf("error building Linode (Akamai) SSH key task: %w", err) + } + name = linode.NormalizeLinodeSSHKeyLabel(name) + + sshKeyResource := fi.Resource(fi.NewBytesResource(b.SSHPublicKeys[0])) + t := &linodetasks.SSHKey{ + Name: fi.PtrTo(name), + Lifecycle: b.Lifecycle, + PublicKey: &sshKeyResource, + } + c.AddTask(t) + + return nil +} diff --git a/pkg/model/linodemodel/sshkey_test.go b/pkg/model/linodemodel/sshkey_test.go new file mode 100644 index 0000000000000..03b66b0b32ce0 --- /dev/null +++ b/pkg/model/linodemodel/sshkey_test.go @@ -0,0 +1,113 @@ +/* +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 ( + "regexp" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/model" + "k8s.io/kops/pkg/model/iam" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linodetasks" +) + +var linodeSSHKeyLabelRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +const testOpenSSHPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCySdqIU+FhCWl3BNrAvPaOe5VfL2aCARUWwy91ZP+T7LBwFa9lhdttfjp/VX1D1/PVwntn2EhN079m8c2kfdmiZ/iCHqrLyIGSd+BOiCz0lT47znvANSfxYjLUuKrWWWeaXqerJkOsAD4PHchRLbZGPdbfoBKwtb/WT4GMRQmb9vmiaZYjsfdPPM9KkWI9ECoWFGjGehA8D+iYIPR711kRacb1xdYmnjHqxAZHFsb5L8wDWIeAyhy49cBD+lbzTiioq2xWLorXuFmXh6Do89PgzvHeyCLY6816f/kCX6wIFts8A2eaEHFL4rAOsuh6qHmSxGCR9peSyuRW8DxV725x justin@test" + +func TestSSHKeyModelBuilderBuild(t *testing.T) { + cluster := &kops.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "example.k8s.local"}} + + builder := &SSHKeyModelBuilder{ + LinodeModelContext: &LinodeModelContext{ + KopsModelContext: &model.KopsModelContext{ + IAMModelContext: iam.IAMModelContext{Cluster: cluster}, + SSHPublicKeys: [][]byte{[]byte(testOpenSSHPublicKey)}, + }, + }, + Lifecycle: fi.LifecycleSync, + } + + context := &fi.CloudupModelBuilderContext{Tasks: map[string]fi.CloudupTask{}} + if err := builder.Build(context); err != nil { + t.Fatalf("Build returned error: %v", err) + } + + if got, want := len(context.Tasks), 1; got != want { + t.Fatalf("unexpected task count: got %d, want %d", got, want) + } + + for _, task := range context.Tasks { + sshKeyTask, ok := task.(*linodetasks.SSHKey) + if !ok { + t.Fatalf("unexpected task type: %T", task) + } + + got := fi.ValueOf(sshKeyTask.Name) + if !strings.HasPrefix(got, "kubernetes-example-k8s-local-") { + t.Fatalf("unexpected generated name prefix: %q", got) + } + if len(got) > 64 { + t.Fatalf("generated name exceeded max length: %d", len(got)) + } + if !linodeSSHKeyLabelRegex.MatchString(got) { + t.Fatalf("generated name has invalid characters: %q", got) + } + if sshKeyTask.PublicKey == nil { + t.Fatalf("expected public key resource to be set") + } + if got, want := sshKeyTask.Lifecycle, fi.LifecycleSync; got != want { + t.Fatalf("unexpected lifecycle: got %q, want %q", got, want) + } + } +} + +func TestSSHKeyModelBuilderBuild_CustomNameNormalized(t *testing.T) { + customName := "custom.key:name" + cluster := &kops.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "example.k8s.local"}} + cluster.Spec.SSHKeyName = fi.PtrTo(customName) + + builder := &SSHKeyModelBuilder{ + LinodeModelContext: &LinodeModelContext{ + KopsModelContext: &model.KopsModelContext{ + IAMModelContext: iam.IAMModelContext{Cluster: cluster}, + SSHPublicKeys: [][]byte{[]byte(testOpenSSHPublicKey)}, + }, + }, + Lifecycle: fi.LifecycleSync, + } + + context := &fi.CloudupModelBuilderContext{Tasks: map[string]fi.CloudupTask{}} + if err := builder.Build(context); err != nil { + t.Fatalf("Build returned error: %v", err) + } + + for _, task := range context.Tasks { + sshKeyTask, ok := task.(*linodetasks.SSHKey) + if !ok { + t.Fatalf("unexpected task type: %T", task) + } + + if got, want := fi.ValueOf(sshKeyTask.Name), "custom-key-name"; got != want { + t.Fatalf("unexpected normalized custom name: got %q, want %q", got, want) + } + } +} diff --git a/pkg/model/master_volumes.go b/pkg/model/master_volumes.go index 5d72e0eb60b5a..8aff277c8050e 100644 --- a/pkg/model/master_volumes.go +++ b/pkg/model/master_volumes.go @@ -37,6 +37,8 @@ import ( "k8s.io/kops/upup/pkg/fi/cloudup/gcetasks" "k8s.io/kops/upup/pkg/fi/cloudup/hetzner" "k8s.io/kops/upup/pkg/fi/cloudup/hetznertasks" + cloudlinode "k8s.io/kops/upup/pkg/fi/cloudup/linode" + "k8s.io/kops/upup/pkg/fi/cloudup/linodetasks" "k8s.io/kops/upup/pkg/fi/cloudup/openstack" "k8s.io/kops/upup/pkg/fi/cloudup/openstacktasks" "k8s.io/kops/upup/pkg/fi/cloudup/scaleway" @@ -127,6 +129,8 @@ func (b *MasterVolumeBuilder) Build(c *fi.CloudupModelBuilderContext) error { case kops.CloudProviderMetal: // Nothing special to do for Metal (yet) + case kops.CloudProviderLinode: + b.addLinodeVolume(c, name, volumeSize, zone, etcd, igName) default: return fmt.Errorf("unknown cloudprovider %q", b.Cluster.GetCloudProvider()) @@ -256,6 +260,23 @@ func (b *MasterVolumeBuilder) addDOVolume(c *fi.CloudupModelBuilderContext, name c.AddTask(t) } +func (b *MasterVolumeBuilder) addLinodeVolume(c *fi.CloudupModelBuilderContext, name string, volumeSize int32, region string, etcd kops.EtcdClusterSpec, instanceGroupName string) { + t := &linodetasks.Volume{ + Name: fi.PtrTo(name), + Lifecycle: b.Lifecycle, + Region: fi.PtrTo(region), + SizeGB: fi.PtrTo(int64(volumeSize)), + Tags: []string{ + cloudlinode.BuildLinodeTag(kops.LabelClusterName, b.Cluster.Name), + cloudlinode.BuildLinodeTag(cloudlinode.TagEtcdClusterName, etcd.Name), + cloudlinode.BuildLinodeTag(cloudlinode.TagKubernetesInstanceRole, string(kops.InstanceGroupRoleControlPlane)), + cloudlinode.BuildLinodeTag(cloudlinode.TagKubernetesInstanceGroup, instanceGroupName), + }, + } + + c.AddTask(t) +} + func (b *MasterVolumeBuilder) addGCEVolume(c *fi.CloudupModelBuilderContext, prefix string, volumeSize int32, zone string, etcd kops.EtcdClusterSpec, m kops.EtcdMemberSpec, allMembers []string) { volumeType := fi.ValueOf(m.VolumeType) if volumeType == "" { diff --git a/pkg/model/master_volumes_test.go b/pkg/model/master_volumes_test.go index 964594b5d5095..6dcab606b413a 100644 --- a/pkg/model/master_volumes_test.go +++ b/pkg/model/master_volumes_test.go @@ -18,6 +18,12 @@ package model import ( "testing" + + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/model/iam" + "k8s.io/kops/pkg/testutils" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linodetasks" ) func TestValidateAWSVolumeAllow50ratio(t *testing.T) { @@ -32,3 +38,42 @@ func TestValidateAWSVolumeAllow50ratio(t *testing.T) { t.Errorf("Failed to validate valid etcd member spec: %v", err) } } + +func TestMasterVolumeBuilderBuildLinode(t *testing.T) { + cluster := testutils.BuildMinimalClusterAWS("linode.k8s.local") + cluster.Spec.CloudProvider = kops.CloudProviderSpec{Linode: &kops.LinodeSpec{}} + + var instanceGroups []*kops.InstanceGroup + for _, subnet := range cluster.Spec.Networking.Subnets { + ig := testutils.BuildMinimalMasterInstanceGroup(subnet.Name) + instanceGroups = append(instanceGroups, &ig) + } + + b := &MasterVolumeBuilder{ + KopsModelContext: &KopsModelContext{ + IAMModelContext: iam.IAMModelContext{Cluster: cluster}, + AllInstanceGroups: instanceGroups, + InstanceGroups: instanceGroups, + }, + } + + c := &fi.CloudupModelBuilderContext{Tasks: map[string]fi.CloudupTask{}} + if err := b.Build(c); err != nil { + t.Fatalf("unexpected error from Build(): %v", err) + } + + expectedTasks := 0 + for _, etcd := range cluster.Spec.EtcdClusters { + expectedTasks += len(etcd.Members) + } + + if got := len(c.Tasks); got != expectedTasks { + t.Fatalf("expected %d master volume tasks for linode, got %d", expectedTasks, got) + } + + for key, task := range c.Tasks { + if _, ok := task.(*linodetasks.Volume); !ok { + t.Fatalf("expected task %q to be *linodetasks.Volume, got %T", key, task) + } + } +} diff --git a/pkg/nodemodel/nodeupconfigbuilder.go b/pkg/nodemodel/nodeupconfigbuilder.go index 17c583a382fe7..9a7ac97205d04 100644 --- a/pkg/nodemodel/nodeupconfigbuilder.go +++ b/pkg/nodemodel/nodeupconfigbuilder.go @@ -355,6 +355,10 @@ func (n *nodeUpConfigBuilder) BuildConfig(ig *kops.InstanceGroup, wellKnownAddre } } + case kops.CloudProviderLinode: + // Linode (Akamai) may expose API through a public load-balancer endpoint; use any discovered API IPs. + controlPlaneIPs = append(controlPlaneIPs, wellKnownAddresses[wellknownservices.KubeAPIServer]...) + case kops.CloudProviderGCE: // Use the IP address of the internal load balancer (forwarding-rule) // Note that on GCE subnets have IP ranges, networks do not @@ -386,7 +390,7 @@ func (n *nodeUpConfigBuilder) BuildConfig(ig *kops.InstanceGroup, wellKnownAddre // This covers the clouds in UseKopsControllerForNodeConfig which use kops-controller for node config, // but don't have a specialized discovery mechanism for finding kops-controller etc. switch cluster.GetCloudProvider() { - case kops.CloudProviderHetzner, kops.CloudProviderScaleway, kops.CloudProviderDO, kops.CloudProviderMetal: + case kops.CloudProviderHetzner, kops.CloudProviderScaleway, kops.CloudProviderDO, kops.CloudProviderMetal, kops.CloudProviderLinode: bootConfig.APIServerIPs = controlPlaneIPs } } diff --git a/pkg/resources/clusterinfo.go b/pkg/resources/clusterinfo.go index 53e92b97fb103..8ccd58f6a1fb5 100644 --- a/pkg/resources/clusterinfo.go +++ b/pkg/resources/clusterinfo.go @@ -19,6 +19,8 @@ package resources type ClusterInfo struct { Name string UsesNoneDNS bool + // Linode (Akamai) specific + LinodeSSHKeyName string // Azure specific AzureStorageAccountID string AzureSubscriptionID string diff --git a/pkg/resources/linode/OWNERS b/pkg/resources/linode/OWNERS new file mode 100644 index 0000000000000..21222f2947b5e --- /dev/null +++ b/pkg/resources/linode/OWNERS @@ -0,0 +1,3 @@ +# See the OWNERS docs at https://go.k8s.io/owners +labels: +- area/provider/linode diff --git a/pkg/resources/linode/resources.go b/pkg/resources/linode/resources.go new file mode 100644 index 0000000000000..b17f394f3ae0d --- /dev/null +++ b/pkg/resources/linode/resources.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" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/linode/linodego" + "k8s.io/kops/pkg/apis/kops" + "k8s.io/kops/pkg/cloudinstances" + "k8s.io/kops/pkg/resources" + "k8s.io/kops/upup/pkg/fi" + cloudlinode "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +const ( + resourceTypeInstance = "instance" + resourceTypeSSHKey = "ssh-key" + resourceTypeVolume = "volume" + resourceTypeNodeBalancer = "nodebalancer" +) + +type listFn func(fi.Cloud, resources.ClusterInfo) ([]*resources.Resource, error) + +func parseTrackerIntID(tracker *resources.Resource) (int, error) { + id, err := strconv.Atoi(tracker.ID) + if err != nil { + return 0, fmt.Errorf("error parsing Linode (Akamai) %s ID %q: %w", tracker.Type, tracker.ID, err) + } + return id, nil +} + +// ListResources collects Linode (Akamai) cloud resources owned by the cluster. +func ListResources(cloud cloudlinode.LinodeCloud, clusterInfo resources.ClusterInfo) (map[string]*resources.Resource, error) { + resourceTrackers := make(map[string]*resources.Resource) + + listFunctions := []listFn{ + listInstances, + listVolumes, + listSSHKeys, + listNodeBalancers, + } + + for _, fn := range listFunctions { + trackers, err := fn(cloud, clusterInfo) + if err != nil { + return nil, err + } + + for _, tracker := range trackers { + resourceTrackers[tracker.Type+":"+tracker.ID] = tracker + } + } + + return resourceTrackers, nil +} + +// listInstances lists Linode (Akamai) instances that are tagged as belonging to the cluster. +func listInstances(cloud fi.Cloud, clusterInfo resources.ClusterInfo) ([]*resources.Resource, error) { + c := cloud.(cloudlinode.LinodeCloud) + instances, err := c.Client().ListInstances(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) instances: %w", err) + } + + clusterTag := cloudlinode.BuildLinodeTag(kops.LabelClusterName, clusterInfo.Name) + var resourceTrackers []*resources.Resource + for _, instance := range instances { + if !slices.Contains(instance.Tags, clusterTag) { + continue + } + + resourceTrackers = append(resourceTrackers, &resources.Resource{ + Name: instance.Label, + ID: strconv.Itoa(instance.ID), + Type: resourceTypeInstance, + Deleter: deleteInstance, + Obj: instance, + }) + } + + return resourceTrackers, nil +} + +// listSSHKeys lists Linode (Akamai) SSH keys that are tagged as belonging to the cluster. +func listSSHKeys(cloud fi.Cloud, clusterInfo resources.ClusterInfo) ([]*resources.Resource, error) { + c := cloud.(cloudlinode.LinodeCloud) + keys, err := c.Client().ListSSHKeys(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) SSH keys: %w", err) + } + + // Precompute match criteria once for the entire key list. + var matchFn func(label string) bool + if clusterInfo.LinodeSSHKeyName != "" { + normalizedName := cloudlinode.NormalizeLinodeSSHKeyLabel(clusterInfo.LinodeSSHKeyName) + matchFn = func(label string) bool { + return label == clusterInfo.LinodeSSHKeyName || label == normalizedName + } + } else { + rawPrefix := "kubernetes." + clusterInfo.Name + "-" + normalizedPrefix := cloudlinode.NormalizeLinodeSSHKeyLabel(rawPrefix) + matchFn = func(label string) bool { + return strings.HasPrefix(label, rawPrefix) || strings.HasPrefix(label, normalizedPrefix) + } + } + + var resourceTrackers []*resources.Resource + for _, key := range keys { + if !matchFn(key.Label) { + continue + } + + resourceTrackers = append(resourceTrackers, &resources.Resource{ + Name: key.Label, + ID: strconv.Itoa(key.ID), + Type: resourceTypeSSHKey, + Deleter: deleteSSHKey, + Obj: key, + }) + } + + return resourceTrackers, nil +} + +// listVolumes lists Linode (Akamai) volumes that are tagged as belonging to the cluster. +func listVolumes(cloud fi.Cloud, clusterInfo resources.ClusterInfo) ([]*resources.Resource, error) { + c := cloud.(cloudlinode.LinodeCloud) + volumes, err := c.Client().ListVolumes(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) volumes: %w", err) + } + + clusterTag := cloudlinode.BuildLinodeTag(kops.LabelClusterName, clusterInfo.Name) + var resourceTrackers []*resources.Resource + for _, volume := range volumes { + if !slices.Contains(volume.Tags, clusterTag) { + continue + } + + resourceTrackers = append(resourceTrackers, &resources.Resource{ + Name: volume.Label, + ID: strconv.Itoa(volume.ID), + Type: resourceTypeVolume, + Deleter: deleteVolume, + Obj: volume, + }) + } + + return resourceTrackers, nil +} + +// listNodeBalancers lists Linode (Akamai) NodeBalancers that are tagged as belonging to the cluster. +func listNodeBalancers(cloud fi.Cloud, clusterInfo resources.ClusterInfo) ([]*resources.Resource, error) { + c := cloud.(cloudlinode.LinodeCloud) + nodeBalancers, err := c.Client().ListNodeBalancers(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) node balancers: %w", err) + } + + clusterTag := cloudlinode.BuildLinodeTag(kops.LabelClusterName, clusterInfo.Name) + var resourceTrackers []*resources.Resource + for _, nodeBalancer := range nodeBalancers { + if !slices.Contains(nodeBalancer.Tags, clusterTag) { + continue + } + + resourceTrackers = append(resourceTrackers, &resources.Resource{ + Name: fi.ValueOf(nodeBalancer.Label), + ID: strconv.Itoa(nodeBalancer.ID), + Type: resourceTypeNodeBalancer, + Deleter: deleteNodeBalancer, + Obj: nodeBalancer, + }) + } + + return resourceTrackers, nil +} + +// deleteInstance deletes a Linode (Akamai) instance. +func deleteInstance(cloud fi.Cloud, tracker *resources.Resource) error { + c := cloud.(cloudlinode.LinodeCloud) + instance := &cloudinstances.CloudInstance{ID: tracker.ID} + if err := c.DeleteInstance(instance); err != nil { + return fmt.Errorf("error deleting Linode (Akamai) instance %s(%s): %w", tracker.Name, tracker.ID, err) + } + + return nil +} + +// deleteSSHKey deletes a Linode (Akamai) SSH key. +func deleteSSHKey(cloud fi.Cloud, tracker *resources.Resource) error { + c := cloud.(cloudlinode.LinodeCloud) + keyID, err := parseTrackerIntID(tracker) + if err != nil { + return err + } + + if err := c.Client().DeleteSSHKey(context.Background(), keyID); err != nil { + if linodego.IsNotFound(err) { + return nil + } + return fmt.Errorf("error deleting Linode (Akamai) SSH key %s(%s): %w", tracker.Name, tracker.ID, err) + } + + return nil +} + +// deleteVolume deletes a Linode (Akamai) volume. +func deleteVolume(cloud fi.Cloud, tracker *resources.Resource) error { + c := cloud.(cloudlinode.LinodeCloud) + volumeID, err := parseTrackerIntID(tracker) + if err != nil { + return err + } + + if err := c.Client().DeleteVolume(context.Background(), volumeID); err != nil { + if linodego.IsNotFound(err) { + return nil + } + return fmt.Errorf("error deleting Linode (Akamai) volume %s(%s): %w", tracker.Name, tracker.ID, err) + } + + return nil +} + +// deleteNodeBalancer deletes a Linode (Akamai) NodeBalancer. +func deleteNodeBalancer(cloud fi.Cloud, tracker *resources.Resource) error { + c := cloud.(cloudlinode.LinodeCloud) + nodeBalancerID, err := parseTrackerIntID(tracker) + if err != nil { + return err + } + + if err := c.Client().DeleteNodeBalancer(context.Background(), nodeBalancerID); err != nil { + if linodego.IsNotFound(err) { + return nil + } + return fmt.Errorf("error deleting Linode (Akamai) node balancer %s(%s): %w", tracker.Name, tracker.ID, err) + } + + return nil +} diff --git a/pkg/resources/linode/resources_test.go b/pkg/resources/linode/resources_test.go new file mode 100644 index 0000000000000..9abf4ccb5c3bc --- /dev/null +++ b/pkg/resources/linode/resources_test.go @@ -0,0 +1,438 @@ +/* +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" + "errors" + "reflect" + "sort" + "testing" + + "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/pkg/resources" + "k8s.io/kops/upup/pkg/fi" + cloudlinode "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +type fakeLinodeClient struct { + listInstancesResponse []linodego.Instance + listInstancesErr error + + listVolumesResponse []linodego.Volume + listVolumesErr error + + listSSHKeysResponse []linodego.SSHKey + listSSHKeysErr error + + listNodeBalancersResponse []linodego.NodeBalancer + listNodeBalancersErr error + + deleteSSHKeyErrByID map[int]error + deletedSSHKeyIDs []int + + deleteVolumeErrByID map[int]error + deletedVolumeIDs []int + + deleteNodeBalancerErrByID map[int]error + deletedNodeBalancerIDs []int +} + +func (f *fakeLinodeClient) ListSSHKeys(ctx context.Context, opts *linodego.ListOptions) ([]linodego.SSHKey, error) { + if f.listSSHKeysErr != nil { + return nil, f.listSSHKeysErr + } + + return f.listSSHKeysResponse, nil +} + +func (f *fakeLinodeClient) CreateSSHKey(ctx context.Context, opts linodego.SSHKeyCreateOptions) (*linodego.SSHKey, error) { + return nil, nil +} + +func (f *fakeLinodeClient) DeleteSSHKey(ctx context.Context, keyID int) error { + f.deletedSSHKeyIDs = append(f.deletedSSHKeyIDs, keyID) + if f.deleteSSHKeyErrByID != nil { + if err := f.deleteSSHKeyErrByID[keyID]; err != nil { + return err + } + } + + return nil +} + +func (f *fakeLinodeClient) ListInstances(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Instance, error) { + if f.listInstancesErr != nil { + return nil, f.listInstancesErr + } + + return f.listInstancesResponse, nil +} + +func (f *fakeLinodeClient) CreateInstance(ctx context.Context, opts linodego.InstanceCreateOptions) (*linodego.Instance, error) { + return nil, nil +} + +func (f *fakeLinodeClient) DeleteInstance(ctx context.Context, linodeID int) error { + return nil +} + +func (f *fakeLinodeClient) UpdateInstance(ctx context.Context, linodeID int, opts linodego.InstanceUpdateOptions) (*linodego.Instance, error) { + return &linodego.Instance{ID: linodeID}, nil +} + +func (f *fakeLinodeClient) ListVolumes(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Volume, error) { + if f.listVolumesErr != nil { + return nil, f.listVolumesErr + } + + return f.listVolumesResponse, nil +} + +func (f *fakeLinodeClient) CreateVolume(ctx context.Context, opts linodego.VolumeCreateOptions) (*linodego.Volume, error) { + return nil, nil +} + +func (f *fakeLinodeClient) DeleteVolume(ctx context.Context, volumeID int) error { + f.deletedVolumeIDs = append(f.deletedVolumeIDs, volumeID) + if f.deleteVolumeErrByID != nil { + if err := f.deleteVolumeErrByID[volumeID]; err != nil { + return err + } + } + + return nil +} + +func (f *fakeLinodeClient) ListNodeBalancers(ctx context.Context, opts *linodego.ListOptions) ([]linodego.NodeBalancer, error) { + if f.listNodeBalancersErr != nil { + return nil, f.listNodeBalancersErr + } + + return f.listNodeBalancersResponse, nil +} + +func (f *fakeLinodeClient) GetNodeBalancer(ctx context.Context, nodebalancerID int) (*linodego.NodeBalancer, error) { + return nil, nil +} + +func (f *fakeLinodeClient) CreateNodeBalancer(ctx context.Context, opts linodego.NodeBalancerCreateOptions) (*linodego.NodeBalancer, error) { + return nil, nil +} + +func (f *fakeLinodeClient) DeleteNodeBalancer(ctx context.Context, nodebalancerID int) error { + f.deletedNodeBalancerIDs = append(f.deletedNodeBalancerIDs, nodebalancerID) + if f.deleteNodeBalancerErrByID != nil { + if err := f.deleteNodeBalancerErrByID[nodebalancerID]; err != nil { + return err + } + } + + return nil +} + +func (f *fakeLinodeClient) ListNodeBalancerConfigs(ctx context.Context, nodebalancerID int, opts *linodego.ListOptions) ([]linodego.NodeBalancerConfig, error) { + return nil, nil +} + +func (f *fakeLinodeClient) CreateNodeBalancerConfig(ctx context.Context, nodebalancerID int, opts linodego.NodeBalancerConfigCreateOptions) (*linodego.NodeBalancerConfig, error) { + return nil, nil +} + +func (f *fakeLinodeClient) RebuildNodeBalancerConfig(ctx context.Context, nodebalancerID int, configID int, opts linodego.NodeBalancerConfigRebuildOptions) (*linodego.NodeBalancerConfig, error) { + return nil, nil +} + +func (f *fakeLinodeClient) ListNodeBalancerNodes(ctx context.Context, nodebalancerID int, configID int, opts *linodego.ListOptions) ([]linodego.NodeBalancerNode, error) { + return nil, nil +} + +func (f *fakeLinodeClient) CreateNodeBalancerNode(ctx context.Context, nodebalancerID int, configID int, opts linodego.NodeBalancerNodeCreateOptions) (*linodego.NodeBalancerNode, error) { + return nil, nil +} + +func (f *fakeLinodeClient) UpdateNodeBalancerNode(ctx context.Context, nodebalancerID int, configID int, nodeID int, opts linodego.NodeBalancerNodeUpdateOptions) (*linodego.NodeBalancerNode, error) { + return nil, nil +} + +type fakeLinodeCloud struct { + client cloudlinode.LinodeClient + deletedInstanceIDs []string + deleteInstanceErr error +} + +var _ cloudlinode.LinodeCloud = &fakeLinodeCloud{} + +func (f *fakeLinodeCloud) ProviderID() kops.CloudProviderID { + return kops.CloudProviderLinode +} + +func (f *fakeLinodeCloud) DNS() (dnsprovider.Interface, error) { + return nil, nil +} + +func (f *fakeLinodeCloud) FindVPCInfo(id string) (*fi.VPCInfo, error) { + return nil, nil +} + +func (f *fakeLinodeCloud) DeleteInstance(instance *cloudinstances.CloudInstance) error { + f.deletedInstanceIDs = append(f.deletedInstanceIDs, instance.ID) + return f.deleteInstanceErr +} + +func (f *fakeLinodeCloud) DeregisterInstance(instance *cloudinstances.CloudInstance) error { + return nil +} + +func (f *fakeLinodeCloud) DeleteGroup(group *cloudinstances.CloudInstanceGroup) error { + return nil +} + +func (f *fakeLinodeCloud) DetachInstance(instance *cloudinstances.CloudInstance) error { + return nil +} + +func (f *fakeLinodeCloud) GetCloudGroups(cluster *kops.Cluster, instancegroups []*kops.InstanceGroup, warnUnmatched bool, nodes []v1.Node) (map[string]*cloudinstances.CloudInstanceGroup, error) { + return nil, nil +} + +func (f *fakeLinodeCloud) Region() string { + return "us-east" +} + +func (f *fakeLinodeCloud) FindClusterStatus(cluster *kops.Cluster) (*kops.ClusterStatus, error) { + return &kops.ClusterStatus{}, nil +} + +func (f *fakeLinodeCloud) GetApiIngressStatus(cluster *kops.Cluster) ([]fi.ApiIngressStatus, error) { + return nil, nil +} + +func (f *fakeLinodeCloud) AccessToken() string { + return "test-token" +} + +func (f *fakeLinodeCloud) Client() cloudlinode.LinodeClient { + return f.client +} + +func TestListResources(t *testing.T) { + client := &fakeLinodeClient{ + listInstancesResponse: []linodego.Instance{ + {ID: 101, Label: "nodes-1", Tags: []string{"kops.k8s.io/cluster:example.k8s.local"}}, + {ID: 102, Label: "nodes-2", Tags: []string{"kops.k8s.io/cluster:other.k8s.local"}}, + }, + listVolumesResponse: []linodego.Volume{ + {ID: 301, Label: "cp-0.etcd-main.example.k8s.local", Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/etcd:main"}}, + {ID: 302, Label: "cp-0.etcd-main.other.k8s.local", Tags: []string{"kops.k8s.io/cluster:other.k8s.local", "kops.k8s.io/etcd:main"}}, + }, + listSSHKeysResponse: []linodego.SSHKey{ + {ID: 501, Label: "kubernetes-example-k8s-local-abc"}, + {ID: 502, Label: "unrelated-key"}, + }, + listNodeBalancersResponse: []linodego.NodeBalancer{ + {ID: 601, Label: linodego.Pointer("api-kops-test-linode-k8s-local"), Tags: []string{"kops.k8s.io/cluster:example.k8s.local"}}, + {ID: 602, Label: linodego.Pointer("api-other"), Tags: []string{"kops.k8s.io/cluster:other.k8s.local"}}, + }, + } + cloud := &fakeLinodeCloud{client: client} + + resourceMap, err := ListResources(cloud, resources.ClusterInfo{Name: "example.k8s.local"}) + if err != nil { + t.Fatalf("ListResources returned error: %v", err) + } + + wantKeys := []string{"instance:101", "nodebalancer:601", "ssh-key:501", "volume:301"} + if gotKeys := sortedResourceKeys(resourceMap); !reflect.DeepEqual(gotKeys, wantKeys) { + t.Fatalf("unexpected resources\nwant: %v\n got: %v", wantKeys, gotKeys) + } + + if r := resourceMap["instance:101"]; r == nil { + t.Fatalf("missing instance:101") + } else if got, want := r.Name, "nodes-1"; got != want { + t.Fatalf("unexpected instance Name: got %q, want %q", got, want) + } else if got, want := r.Type, resourceTypeInstance; got != want { + t.Fatalf("unexpected instance Type: got %q, want %q", got, want) + } + + if r := resourceMap["ssh-key:501"]; r == nil { + t.Fatalf("missing ssh-key:501") + } else if got, want := r.Type, resourceTypeSSHKey; got != want { + t.Fatalf("unexpected SSH key Type: got %q, want %q", got, want) + } +} + +func TestListResources_CustomSSHKeyName(t *testing.T) { + client := &fakeLinodeClient{ + listSSHKeysResponse: []linodego.SSHKey{ + {ID: 501, Label: "kubernetes.example.k8s.local-abc"}, + {ID: 777, Label: "my-custom-key-name"}, + }, + } + cloud := &fakeLinodeCloud{client: client} + + resourceMap, err := ListResources(cloud, resources.ClusterInfo{Name: "example.k8s.local", LinodeSSHKeyName: "my.custom:key:name"}) + if err != nil { + t.Fatalf("ListResources returned error: %v", err) + } + + wantKeys := []string{"ssh-key:777"} + if gotKeys := sortedResourceKeys(resourceMap); !reflect.DeepEqual(gotKeys, wantKeys) { + t.Fatalf("unexpected resources\nwant: %v\n got: %v", wantKeys, gotKeys) + } +} + +func TestListResources_DefaultSSHKeyNameLegacyAndNormalized(t *testing.T) { + client := &fakeLinodeClient{ + listSSHKeysResponse: []linodego.SSHKey{ + {ID: 501, Label: "kubernetes.example.k8s.local-legacy"}, + {ID: 502, Label: "kubernetes-example-k8s-local-normalized"}, + {ID: 503, Label: "unrelated-key"}, + }, + } + cloud := &fakeLinodeCloud{client: client} + + resourceMap, err := ListResources(cloud, resources.ClusterInfo{Name: "example.k8s.local"}) + if err != nil { + t.Fatalf("ListResources returned error: %v", err) + } + + wantKeys := []string{"ssh-key:501", "ssh-key:502"} + if gotKeys := sortedResourceKeys(resourceMap); !reflect.DeepEqual(gotKeys, wantKeys) { + t.Fatalf("unexpected resources\nwant: %v\n got: %v", wantKeys, gotKeys) + } +} + +func TestDeleteInstance(t *testing.T) { + cloud := &fakeLinodeCloud{client: &fakeLinodeClient{}} + + tracker := &resources.Resource{Name: "nodes-1", ID: "101", Type: resourceTypeInstance} + if err := deleteInstance(cloud, tracker); err != nil { + t.Fatalf("deleteInstance returned error: %v", err) + } + + if !reflect.DeepEqual(cloud.deletedInstanceIDs, []string{"101"}) { + t.Fatalf("unexpected deleted instance IDs: %v", cloud.deletedInstanceIDs) + } +} + +func TestDeleteSSHKey(t *testing.T) { + client := &fakeLinodeClient{} + cloud := &fakeLinodeCloud{client: client} + + tracker := &resources.Resource{Name: "kubernetes.example.k8s.local-abc", ID: "501", Type: resourceTypeSSHKey} + if err := deleteSSHKey(cloud, tracker); err != nil { + t.Fatalf("deleteSSHKey returned error: %v", err) + } + + if !reflect.DeepEqual(client.deletedSSHKeyIDs, []int{501}) { + t.Fatalf("unexpected deleted SSH key IDs: %v", client.deletedSSHKeyIDs) + } +} + +func TestDeleteSSHKey_NotFound(t *testing.T) { + client := &fakeLinodeClient{ + deleteSSHKeyErrByID: map[int]error{ + 501: &linodego.Error{Code: 404, Message: "not found"}, + }, + } + cloud := &fakeLinodeCloud{client: client} + + tracker := &resources.Resource{Name: "kubernetes.example.k8s.local-abc", ID: "501", Type: resourceTypeSSHKey} + if err := deleteSSHKey(cloud, tracker); err != nil { + t.Fatalf("deleteSSHKey returned error for not found response: %v", err) + } +} + +func TestDeleteVolume(t *testing.T) { + client := &fakeLinodeClient{} + cloud := &fakeLinodeCloud{client: client} + + tracker := &resources.Resource{Name: "cp-0.etcd-main.example.k8s.local", ID: "301", Type: resourceTypeVolume} + if err := deleteVolume(cloud, tracker); err != nil { + t.Fatalf("deleteVolume returned error: %v", err) + } + + if !reflect.DeepEqual(client.deletedVolumeIDs, []int{301}) { + t.Fatalf("unexpected deleted volume IDs: %v", client.deletedVolumeIDs) + } +} + +func TestDeleteVolume_NotFound(t *testing.T) { + client := &fakeLinodeClient{ + deleteVolumeErrByID: map[int]error{ + 301: &linodego.Error{Code: 404, Message: "not found"}, + }, + } + cloud := &fakeLinodeCloud{client: client} + + tracker := &resources.Resource{Name: "cp-0.etcd-main.example.k8s.local", ID: "301", Type: resourceTypeVolume} + if err := deleteVolume(cloud, tracker); err != nil { + t.Fatalf("deleteVolume returned error for not found response: %v", err) + } +} + +func TestDeleteNodeBalancer(t *testing.T) { + client := &fakeLinodeClient{} + cloud := &fakeLinodeCloud{client: client} + + tracker := &resources.Resource{Name: "api-kops-test-linode-k8s-local", ID: "601", Type: resourceTypeNodeBalancer} + if err := deleteNodeBalancer(cloud, tracker); err != nil { + t.Fatalf("deleteNodeBalancer returned error: %v", err) + } + + if !reflect.DeepEqual(client.deletedNodeBalancerIDs, []int{601}) { + t.Fatalf("unexpected deleted node balancer IDs: %v", client.deletedNodeBalancerIDs) + } +} + +func TestDeleteNodeBalancer_NotFound(t *testing.T) { + client := &fakeLinodeClient{ + deleteNodeBalancerErrByID: map[int]error{ + 601: &linodego.Error{Code: 404, Message: "not found"}, + }, + } + cloud := &fakeLinodeCloud{client: client} + + tracker := &resources.Resource{Name: "api-kops-test-linode-k8s-local", ID: "601", Type: resourceTypeNodeBalancer} + if err := deleteNodeBalancer(cloud, tracker); err != nil { + t.Fatalf("deleteNodeBalancer returned error for not found response: %v", err) + } +} + +func TestListResources_PropagatesErrors(t *testing.T) { + client := &fakeLinodeClient{listInstancesErr: errors.New("instances API down")} + cloud := &fakeLinodeCloud{client: client} + + _, err := ListResources(cloud, resources.ClusterInfo{Name: "example.k8s.local"}) + if err == nil { + t.Fatalf("expected error when listing instances") + } +} + +func sortedResourceKeys(m map[string]*resources.Resource) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/pkg/resources/ops/collector.go b/pkg/resources/ops/collector.go index 331c1b70eff62..360cfd09b83ae 100644 --- a/pkg/resources/ops/collector.go +++ b/pkg/resources/ops/collector.go @@ -26,6 +26,7 @@ import ( "k8s.io/kops/pkg/resources/digitalocean" "k8s.io/kops/pkg/resources/gce" "k8s.io/kops/pkg/resources/hetzner" + "k8s.io/kops/pkg/resources/linode" "k8s.io/kops/pkg/resources/openstack" "k8s.io/kops/pkg/resources/scaleway" "k8s.io/kops/upup/pkg/fi" @@ -34,6 +35,7 @@ import ( clouddo "k8s.io/kops/upup/pkg/fi/cloudup/do" cloudgce "k8s.io/kops/upup/pkg/fi/cloudup/gce" cloudhetzner "k8s.io/kops/upup/pkg/fi/cloudup/hetzner" + cloudlinode "k8s.io/kops/upup/pkg/fi/cloudup/linode" cloudopenstack "k8s.io/kops/upup/pkg/fi/cloudup/openstack" cloudscaleway "k8s.io/kops/upup/pkg/fi/cloudup/scaleway" ) @@ -54,6 +56,13 @@ func ListResources(cloud fi.Cloud, cluster *kops.Cluster) (map[string]*resources return gce.ListResourcesGCE(cloud.(cloudgce.GCECloud), clusterInfo) case kops.CloudProviderHetzner: return hetzner.ListResources(cloud.(cloudhetzner.HetznerCloud), clusterInfo) + case kops.CloudProviderLinode: + // Pass custom SSH key name (if specified) for deletion matching. + // Linode (Akamai) SSH keys don't support tagging, so we match by name pattern. + if cluster.Spec.SSHKeyName != nil { + clusterInfo.LinodeSSHKeyName = *cluster.Spec.SSHKeyName + } + return linode.ListResources(cloud.(cloudlinode.LinodeCloud), clusterInfo) case kops.CloudProviderOpenstack: return openstack.ListResources(cloud.(cloudopenstack.OpenstackCloud), clusterInfo) case kops.CloudProviderAzure: diff --git a/upup/models/cloudup/resources/addons/dns-controller.addons.k8s.io/k8s-1.12.yaml.template b/upup/models/cloudup/resources/addons/dns-controller.addons.k8s.io/k8s-1.12.yaml.template index e77aaa32060f2..9b1e696f99dba 100644 --- a/upup/models/cloudup/resources/addons/dns-controller.addons.k8s.io/k8s-1.12.yaml.template +++ b/upup/models/cloudup/resources/addons/dns-controller.addons.k8s.io/k8s-1.12.yaml.template @@ -78,6 +78,14 @@ spec: - secretRef: name: scaleway-secret {{- end }} +{{- if eq GetCloudProvider "linode" }} + - name: LINODE_TOKEN + valueFrom: + secretKeyRef: + name: linode + key: apiToken +{{- end }} + resources: requests: cpu: 50m diff --git a/upup/models/cloudup/resources/addons/linode-cloud-controller.addons.k8s.io/k8s-1.22.yaml.template b/upup/models/cloudup/resources/addons/linode-cloud-controller.addons.k8s.io/k8s-1.22.yaml.template new file mode 100644 index 0000000000000..adb81f0decc47 --- /dev/null +++ b/upup/models/cloudup/resources/addons/linode-cloud-controller.addons.k8s.io/k8s-1.22.yaml.template @@ -0,0 +1,181 @@ +# Linode Cloud Controller Manager Addon +# Based on https://github.com/linode/linode-cloud-controller-manager/tree/main/deploy/chart + +--- +apiVersion: v1 +kind: Secret +metadata: + name: linode + namespace: kube-system +type: Opaque +stringData: + apiToken: "{{ LINODE_TOKEN }}" + region: "{{ LINODE_REGION }}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: linode-cloud-controller-manager + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: system:linode-cloud-controller-manager +rules: +- apiGroups: [""] + resources: ["endpoints"] + verbs: ["get", "watch", "list", "update", "create"] +- apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "watch", "list", "update", "create"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["get", "watch", "list", "update", "delete", "patch"] +- apiGroups: [""] + resources: ["nodes/status"] + verbs: ["get", "watch", "list", "update", "delete", "patch"] +- apiGroups: [""] + resources: ["events"] + verbs: ["get", "watch", "list", "update", "create", "patch"] +- apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["get", "watch", "list", "update"] +- apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["create", "get"] +- apiGroups: [""] + resources: ["serviceaccounts/token"] + verbs: ["create"] +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] +- apiGroups: [""] + resources: ["services"] + verbs: ["get", "watch", "list"] +- apiGroups: [""] + resources: ["services/status"] + verbs: ["get", "watch", "list", "update", "patch"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: system:linode-cloud-controller-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:linode-cloud-controller-manager +subjects: +- kind: ServiceAccount + name: linode-cloud-controller-manager + namespace: kube-system +- kind: ServiceAccount + name: linode-shared-informers + namespace: kube-system +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: system:linode-shared-informers +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:linode-cloud-controller-manager +subjects: +- kind: ServiceAccount + name: linode-shared-informers + namespace: kube-system +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: linode-cloud-controller-manager + labels: + app: linode-cloud-controller-manager + namespace: kube-system +spec: + selector: + matchLabels: + app: linode-cloud-controller-manager + template: + metadata: + labels: + app: linode-cloud-controller-manager + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: node-role.kubernetes.io/control-plane + operator: Exists + - matchExpressions: + - key: node-role.kubernetes.io/master + operator: Exists + securityContext: + seccompProfile: + type: RuntimeDefault + serviceAccountName: linode-cloud-controller-manager + dnsPolicy: Default + hostNetwork: true + priorityClassName: system-cluster-critical + tolerations: + # Allow CCM to run on control plane nodes + - key: "node-role.kubernetes.io/control-plane" + effect: "NoSchedule" + - key: "node-role.kubernetes.io/master" + effect: "NoSchedule" + # CCM is a critical addon + - key: "CriticalAddonsOnly" + operator: "Exists" + # Allow CCM to schedule on nodes with uninitialized taint (it removes this taint) + - key: "node.cloudprovider.kubernetes.io/uninitialized" + value: "true" + effect: "NoSchedule" + # Allow scheduling on not-ready/unreachable nodes + - key: "node.kubernetes.io/not-ready" + operator: "Exists" + effect: "NoSchedule" + - key: "node.kubernetes.io/unreachable" + operator: "Exists" + effect: "NoSchedule" + containers: + - name: linode-cloud-controller-manager + image: '{{ .ExternalCloudControllerManager.Image }}' + imagePullPolicy: IfNotPresent + args: + - --leader-elect-resource-lock=leases + {{- range $arg := CloudControllerConfigArgv }} + - "{{ $arg }}" + {{- end }} + env: + - name: LINODE_API_TOKEN + valueFrom: + secretKeyRef: + name: linode + key: apiToken + - name: LINODE_REGION + valueFrom: + secretKeyRef: + name: linode + key: region + volumeMounts: + - mountPath: /etc/kubernetes + name: k8s + readOnly: true + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + resources: + requests: + cpu: 100m + memory: 128Mi + ports: + - name: metrics + containerPort: 10253 + volumes: + - name: k8s + hostPath: + path: /etc/kubernetes diff --git a/upup/models/cloudup/resources/addons/linode-csi-driver.addons.k8s.io/k8s-1.22.yaml.template b/upup/models/cloudup/resources/addons/linode-csi-driver.addons.k8s.io/k8s-1.22.yaml.template new file mode 100644 index 0000000000000..5cdbfb925dd35 --- /dev/null +++ b/upup/models/cloudup/resources/addons/linode-csi-driver.addons.k8s.io/k8s-1.22.yaml.template @@ -0,0 +1,558 @@ +# Vendored from the upstream linode-blockstorage-csi-driver chart https://github.com/linode/linode-blockstorage-csi-driver/tree/main/helm-chart/csi-driver +# Command to render the template: +# helm template linode-csi linode-blockstorage-csi-driver -n kube-system \ +# --set apiToken='{{ LINODE_TOKEN }}' \ +# --set defaultStorageClass=linode-block-storage-retain \ +# --no-hooks | grep -v 'Source: ' +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: csi-controller-sa + namespace: kube-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: csi-node-sa + namespace: kube-system +--- +apiVersion: v1 +kind: Secret +metadata: + name: linode + namespace: kube-system +stringData: + token: {{ LINODE_TOKEN }} +type: Opaque +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: linode-block-storage-retain + namespace: kube-system + annotations: + storageclass.kubernetes.io/is-default-class: "true" +allowVolumeExpansion: true +provisioner: linodebs.csi.linode.com +reclaimPolicy: Retain +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: linode-block-storage + namespace: kube-system +allowVolumeExpansion: true +provisioner: linodebs.csi.linode.com +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-attacher-role +rules: +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - get + - list + - watch + - update + - patch +- apiGroups: + - storage.k8s.io + resources: + - csinodes + verbs: + - get + - list + - watch +- apiGroups: + - storage.k8s.io + resources: + - volumeattachments + - volumeattachments/status + verbs: + - get + - list + - watch + - update + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-provisioner-role +rules: +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - get + - list + - watch + - create + - delete +- apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - get + - list + - watch + - update +- apiGroups: + - storage.k8s.io + resources: + - storageclasses + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - list + - watch + - create + - update + - patch +- apiGroups: + - snapshot.storage.k8s.io + resources: + - volumesnapshots + verbs: + - get + - list +- apiGroups: + - snapshot.storage.k8s.io + resources: + - volumesnapshotcontents + verbs: + - get + - list +- apiGroups: + - storage.k8s.io + resources: + - csinodes + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - nodes + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-resizer-role +rules: +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - get + - list + - watch + - patch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - persistentvolumeclaims/status + verbs: + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - list + - watch + - create + - update + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: linode-csi-role + namespace: kube-system +rules: +- apiGroups: + - "" + resources: + - events + verbs: + - get + - list + - watch + - create + - update + - patch +- apiGroups: + - "" + resources: + - nodes + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: csi-controller-attacher-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-attacher-role +subjects: +- kind: ServiceAccount + name: csi-controller-sa + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: csi-controller-provisioner-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-provisioner-role +subjects: +- kind: ServiceAccount + name: csi-controller-sa + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: csi-controller-resizer-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-resizer-role +subjects: +- kind: ServiceAccount + name: csi-controller-sa + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: linode-csi-binding + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: linode-csi-role +subjects: +- kind: ServiceAccount + name: csi-node-sa + namespace: kube-system +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: csi-linode-node + namespace: kube-system + labels: + app: csi-linode-node +spec: + selector: + matchLabels: + app: csi-linode-node + template: + metadata: + labels: + app: csi-linode-node + role: csi-linode + spec: + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - args: + - --v=2 + - --csi-address=$(ADDRESS) + - --kubelet-registration-path=$(DRIVER_REG_SOCK_PATH) + env: + - name: ADDRESS + value: /csi/csi.sock + - name: DRIVER_REG_SOCK_PATH + value: /var/lib/kubelet/plugins/linodebs.csi.linode.com/csi.sock + - name: KUBE_NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + image: registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.14.0 + name: csi-node-driver-registrar + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - mountPath: /csi + name: plugin-dir + - mountPath: /registration + name: registration-dir + - args: + - --v=2 + env: + - name: CSI_ENDPOINT + value: unix:///csi/csi.sock + - name: DRIVER_ROLE + value: nodeserver + - name: ENABLE_METRICS + value: "false" + - name: METRICS_PORT + value: "8081" + image: linode/linode-blockstorage-csi-driver:v1.1.1 + imagePullPolicy: IfNotPresent + name: csi-linode-plugin + securityContext: + # This container must run as privileged due to the requirement for bidirectional mount propagation + # See https://github.com/kubernetes/kubernetes/issues/94400 + privileged: true + allowPrivilegeEscalation: true + capabilities: + drop: + - ALL + add: + - SYS_ADMIN + volumeMounts: + - mountPath: /csi + name: plugin-dir + - mountPath: /var/lib/kubelet + mountPropagation: Bidirectional + name: pods-mount-dir + - mountPath: /dev + name: device-dir + - mountPath: /tmp + name: tmp + hostNetwork: true + priorityClassName: system-node-critical + serviceAccount: csi-node-sa + tolerations: + - effect: NoSchedule + operator: Exists + - key: CriticalAddonsOnly + operator: Exists + - effect: NoExecute + operator: Exists + volumes: + - hostPath: + path: /var/lib/kubelet/plugins_registry/ + type: DirectoryOrCreate + name: registration-dir + - hostPath: + path: /var/lib/kubelet + type: Directory + name: kubelet-dir + - hostPath: + path: /var/lib/kubelet/plugins/linodebs.csi.linode.com + type: DirectoryOrCreate + name: plugin-dir + - hostPath: + path: /var/lib/kubelet + type: Directory + name: pods-mount-dir + - hostPath: + path: /dev + name: device-dir + - hostPath: + path: /etc/udev + type: Directory + name: udev-rules-etc + - hostPath: + path: /lib/udev + type: Directory + name: udev-rules-lib + - hostPath: + path: /run/udev + type: Directory + name: udev-socket + - hostPath: + path: /sys + type: Directory + name: sys + - hostPath: + path: /tmp + type: Directory + name: tmp +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: csi-linode-controller + namespace: kube-system + labels: + app: csi-linode-controller +spec: + replicas: 1 + selector: + matchLabels: + app: csi-linode-controller + serviceName: csi-linode + template: + metadata: + labels: + app: csi-linode-controller + role: csi-linode + spec: + hostNetwork: false + dnsPolicy: Default + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - args: + - --default-fstype=ext4 + - --volume-name-prefix=pvc + - --volume-name-uuid-length=16 + - --csi-address=$(ADDRESS) + - --feature-gates=Topology=true + - --v=2 + env: + - name: ADDRESS + value: /var/lib/csi/sockets/pluginproxy/csi.sock + image: registry.k8s.io/sig-storage/csi-provisioner:v5.3.0 + imagePullPolicy: IfNotPresent + name: csi-provisioner + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - mountPath: /var/lib/csi/sockets/pluginproxy/ + name: socket-dir + - args: + - --v=2 + - --csi-address=$(ADDRESS) + - --timeout=30s + env: + - name: ADDRESS + value: /var/lib/csi/sockets/pluginproxy/csi.sock + image: registry.k8s.io/sig-storage/csi-attacher:v4.9.0 + imagePullPolicy: IfNotPresent + name: csi-attacher + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - mountPath: /var/lib/csi/sockets/pluginproxy/ + name: socket-dir + - args: + - --v=2 + - --csi-address=$(ADDRESS) + env: + - name: ADDRESS + value: /var/lib/csi/sockets/pluginproxy/csi.sock + image: registry.k8s.io/sig-storage/csi-resizer:v1.14.0 + imagePullPolicy: IfNotPresent + name: csi-resizer + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - mountPath: /var/lib/csi/sockets/pluginproxy/ + name: socket-dir + - args: + - --v=2 + env: + - name: CSI_ENDPOINT + value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock + - name: LINODE_URL + value: https://api.linode.com + - name: DRIVER_ROLE + value: controller + - name: LINODE_VOLUME_LABEL_PREFIX + value: "" + - name: NODE_NAME + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: spec.nodeName + - name: LINODE_TOKEN + valueFrom: + secretKeyRef: + name: "linode" + key: "token" + - name: ENABLE_METRICS + value: "false" + - name: METRICS_PORT + value: "8081" + - name: OTEL_TRACING + value: "false" + - name: OTEL_TRACING_PORT + value: "4318" + image: linode/linode-blockstorage-csi-driver:v1.1.1 + imagePullPolicy: IfNotPresent + name: csi-linode-plugin + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - mountPath: /var/lib/csi/sockets/pluginproxy/ + name: socket-dir + serviceAccount: csi-controller-sa + tolerations: + - key: CriticalAddonsOnly + operator: Exists + - effect: NoExecute + operator: Exists + volumes: + - emptyDir: {} + name: socket-dir + - configMap: + defaultMode: 493 + name: get-linode-id + name: get-linode-id + - hostPath: + path: /dev + type: Directory + name: dev +--- +apiVersion: storage.k8s.io/v1 +kind: CSIDriver +metadata: + name: linodebs.csi.linode.com +spec: + attachRequired: true + podInfoOnMount: true diff --git a/upup/pkg/fi/cloudup/apply_cluster.go b/upup/pkg/fi/cloudup/apply_cluster.go index ea96879a3b0f9..b79fc81666b70 100644 --- a/upup/pkg/fi/cloudup/apply_cluster.go +++ b/upup/pkg/fi/cloudup/apply_cluster.go @@ -48,6 +48,7 @@ import ( "k8s.io/kops/pkg/model/gcemodel" "k8s.io/kops/pkg/model/hetznermodel" "k8s.io/kops/pkg/model/iam" + "k8s.io/kops/pkg/model/linodemodel" "k8s.io/kops/pkg/model/openstackmodel" "k8s.io/kops/pkg/model/scalewaymodel" "k8s.io/kops/pkg/nodemodel" @@ -61,6 +62,7 @@ import ( "k8s.io/kops/upup/pkg/fi/cloudup/do" "k8s.io/kops/upup/pkg/fi/cloudup/gce" "k8s.io/kops/upup/pkg/fi/cloudup/hetzner" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" "k8s.io/kops/upup/pkg/fi/cloudup/metal" "k8s.io/kops/upup/pkg/fi/cloudup/openstack" "k8s.io/kops/upup/pkg/fi/cloudup/scaleway" @@ -496,6 +498,19 @@ func (c *ApplyClusterCmd) Run(ctx context.Context) (*ApplyResults, error) { scwZone = scwCloud.Zone() } + case kops.CloudProviderLinode: + { + if !featureflag.Linode.Enabled() { + return nil, fmt.Errorf("Linode (Akamai) support is currently alpha, and is feature-gated. Please export KOPS_FEATURE_FLAGS=Linode") + } + if len(sshPublicKeys) == 0 { + return nil, fmt.Errorf("SSH public key must be specified when running with Linode (Akamai) (create with `kops create secret --name %s sshpublickey admin -i ~/.ssh/id_rsa.pub`)", cluster.ObjectMeta.Name) + } + if len(sshPublicKeys) != 1 { + return nil, fmt.Errorf("exactly one 'admin' SSH public key can be specified when running with Linode (Akamai); please delete a key using `kops delete secret`") + } + } + case kops.CloudProviderMetal: // Metal is a special case, we don't need to do anything here (yet) @@ -710,6 +725,17 @@ func (c *ApplyClusterCmd) Run(ctx context.Context) (*ApplyResults, error) { case kops.CloudProviderMetal: // No special builders for bare metal (yet) + case kops.CloudProviderLinode: + linodeModelContext := &linodemodel.LinodeModelContext{ + KopsModelContext: modelContext, + } + l.Builders = append(l.Builders, + &linodemodel.APILoadBalancerModelBuilder{LinodeModelContext: linodeModelContext, Lifecycle: clusterLifecycle}, + &linodemodel.DNSModelBuilder{LinodeModelContext: linodeModelContext, Lifecycle: clusterLifecycle}, + &linodemodel.SSHKeyModelBuilder{LinodeModelContext: linodeModelContext, Lifecycle: securityLifecycle}, + &linodemodel.InstanceModelBuilder{LinodeModelContext: linodeModelContext, BootstrapScriptBuilder: bootstrapScriptBuilder, Lifecycle: clusterLifecycle}, + ) + default: return nil, fmt.Errorf("unknown cloudprovider %q", cluster.GetCloudProvider()) } @@ -740,6 +766,8 @@ func (c *ApplyClusterCmd) Run(ctx context.Context) (*ApplyResults, error) { target = azure.NewAzureAPITarget(cloud.(azure.AzureCloud)) case kops.CloudProviderScaleway: target = scaleway.NewScwAPITarget(cloud.(scaleway.ScwCloud)) + case kops.CloudProviderLinode: + target = linode.NewAPITarget(cloud.(linode.LinodeCloud)) case kops.CloudProviderMetal: target = metal.NewAPITarget(cloud.(*metal.Cloud), nil) default: diff --git a/upup/pkg/fi/cloudup/bootstrapchannelbuilder/bootstrapchannelbuilder.go b/upup/pkg/fi/cloudup/bootstrapchannelbuilder/bootstrapchannelbuilder.go index 03adcb3dfbe8b..18c818ab3a5f4 100644 --- a/upup/pkg/fi/cloudup/bootstrapchannelbuilder/bootstrapchannelbuilder.go +++ b/upup/pkg/fi/cloudup/bootstrapchannelbuilder/bootstrapchannelbuilder.go @@ -772,6 +772,33 @@ func (b *BootstrapChannelBuilder) buildAddons(c *fi.CloudupModelBuilderContext) } } + if b.Cluster.GetCloudProvider() == kops.CloudProviderLinode { + { + key := "linode-cloud-controller.addons.k8s.io" + id := "k8s-1.22" + location := key + "/" + id + ".yaml" + + addons.Add(&channelsapi.AddonSpec{ + Name: fi.PtrTo(key), + Selector: map[string]string{"k8s-addon": key}, + Manifest: fi.PtrTo(location), + Id: id, + }) + } + { + key := "linode-csi-driver.addons.k8s.io" + id := "k8s-1.22" + location := key + "/" + id + ".yaml" + + addons.Add(&channelsapi.AddonSpec{ + Name: fi.PtrTo(key), + Selector: map[string]string{"k8s-addon": key}, + Manifest: fi.PtrTo(location), + Id: id, + }) + } + } + if b.Cluster.IsKubernetesGTE("1.31") && b.Cluster.GetCloudProvider() == kops.CloudProviderAzure { { key := "azure-cloud-controller.addons.k8s.io" 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..8011263274981 --- /dev/null +++ b/upup/pkg/fi/cloudup/linode/cloud.go @@ -0,0 +1,493 @@ +/* +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" + linodedns "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/linode" + "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 the Linode (Akamai) DNS provider. +func (c *Cloud) DNS() (dnsprovider.Interface, error) { + provider, err := dnsprovider.GetDnsProvider(linodedns.ProviderName, nil) + if err != nil { + return nil, fmt.Errorf("error building DNS provider: %w", err) + } + return provider, 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/dnsrecord.go b/upup/pkg/fi/cloudup/linodetasks/dnsrecord.go new file mode 100644 index 0000000000000..eaa85918c7977 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/dnsrecord.go @@ -0,0 +1,256 @@ +/* +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" + "strings" + + "github.com/linode/linodego" + "k8s.io/klog/v2" + dnspkg "k8s.io/kops/dns-controller/pkg/dns" + "k8s.io/kops/dnsprovider/pkg/dnsprovider" + "k8s.io/kops/dnsprovider/pkg/dnsprovider/rrstype" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +// +kops:fitask +type DNSRecord struct { + Name *string + ResourceName *string + Lifecycle fi.Lifecycle + + RecordType *string + Target fi.HasAddress +} + +var _ fi.CloudupTask = &DNSRecord{} + +func (d *DNSRecord) Find(c *fi.CloudupContext) (*DNSRecord, error) { + cloud := c.T.Cloud.(linode.LinodeCloud) + + dns, err := cloud.DNS() + if err != nil { + return nil, fmt.Errorf("error getting DNS provider: %w", err) + } + + if dns == nil { + klog.V(2).Infof("DNS provider not available for %s", fi.ValueOf(d.ResourceName)) + return nil, nil + } + + zone, err := findDNSZone(dns, fi.ValueOf(d.ResourceName)) + if err != nil { + return nil, err + } + if zone == nil { + klog.V(2).Infof("DNS zone not found for %s, skipping DNS record", fi.ValueOf(d.ResourceName)) + return nil, nil + } + + rrs, supported := zone.ResourceRecordSets() + if !supported { + return nil, fmt.Errorf("zone %q does not support resource record sets", zone.Name()) + } + + // Look for existing record + recordName := dnspkg.EnsureDotSuffix(fi.ValueOf(d.ResourceName)) + records, err := rrs.Get(recordName) + if err != nil { + return nil, fmt.Errorf("error querying DNS records for %q: %w", recordName, err) + } + + klog.V(4).Infof("Found %d DNS records for %s in zone %s", len(records), recordName, zone.Name()) + + recordType := fi.ValueOf(d.RecordType) + for _, record := range records { + klog.V(4).Infof("Checking record type %s (want %s)", record.Type(), recordType) + if string(record.Type()) != recordType { + continue + } + + // Found existing record with matching type + klog.V(2).Infof("Found existing DNS record for %s type %s", recordName, recordType) + actual := &DNSRecord{ + Name: d.Name, + ResourceName: d.ResourceName, + Lifecycle: d.Lifecycle, + RecordType: d.RecordType, + Target: d.Target, + } + return actual, nil + } + + klog.V(2).Infof("No existing DNS record found for %s type %s", recordName, recordType) + return nil, nil +} + +func (d *DNSRecord) Run(c *fi.CloudupContext) error { + return fi.CloudupDefaultDeltaRunMethod(d, c) +} + +func (*DNSRecord) CheckChanges(a, e, changes *DNSRecord) error { + if e.ResourceName == nil { + return fi.RequiredField("ResourceName") + } + if e.RecordType == nil { + return fi.RequiredField("RecordType") + } + if e.Target == nil { + return fi.RequiredField("Target") + } + return nil +} + +func (*DNSRecord) RenderLinode(t *linode.APITarget, a, e, changes *DNSRecord) error { + cloud := t.Cloud + + dnsProvider, err := cloud.DNS() + if err != nil { + return fmt.Errorf("error getting DNS provider: %w", err) + } + + if dnsProvider == nil { + klog.Infof("DNS provider not available, skipping DNS record creation for %s", fi.ValueOf(e.ResourceName)) + return nil + } + + zone, err := findDNSZone(dnsProvider, fi.ValueOf(e.ResourceName)) + if err != nil { + return err + } + if zone == nil { + return fmt.Errorf("DNS zone not found for %s", fi.ValueOf(e.ResourceName)) + } + + rrs, supported := zone.ResourceRecordSets() + if !supported { + return fmt.Errorf("zone %q does not support resource record sets", zone.Name()) + } + + // Get the target's ID to query the load balancer + targetLB, ok := e.Target.(*LoadBalancer) + if !ok { + return fmt.Errorf("target is not a LoadBalancer") + } + + if targetLB.ID == nil { + klog.V(4).Infof("Target LoadBalancer has no ID yet, skipping DNS record creation for %s", fi.ValueOf(e.ResourceName)) + return nil + } + + // Query the actual load balancer to get its IP + nodebalancer, err := cloud.Client().GetNodeBalancer(context.Background(), fi.ValueOf(targetLB.ID)) + if err != nil { + if linodego.IsNotFound(err) { + klog.V(4).Infof("Target LoadBalancer not yet created, skipping DNS record creation for %s", fi.ValueOf(e.ResourceName)) + return nil + } + return fmt.Errorf("error getting load balancer: %w", err) + } + + if nodebalancer == nil || nodebalancer.IPv4 == nil || *nodebalancer.IPv4 == "" { + klog.V(4).Infof("Target LoadBalancer has no IP yet, skipping DNS record creation for %s", fi.ValueOf(e.ResourceName)) + return nil + } + + targetIP := *nodebalancer.IPv4 + recordName := dnspkg.EnsureDotSuffix(fi.ValueOf(e.ResourceName)) + recordType := rrstype.RrsType(fi.ValueOf(e.RecordType)) + + // Check existing records before creating changeset + existing, err := rrs.Get(recordName) + if err != nil { + return fmt.Errorf("error querying existing DNS records: %w", err) + } + + // Check if a record already exists with the correct IP + for _, record := range existing { + if record.Type() == recordType { + existingIPs := record.Rrdatas() + if len(existingIPs) == 1 && existingIPs[0] == targetIP { + klog.V(2).Infof("DNS record %s %s already points to %s, skipping update", recordName, recordType, targetIP) + return nil + } + klog.V(2).Infof("DNS record %s %s exists but points to %v (want %s), updating", recordName, recordType, existingIPs, targetIP) + break + } + } + + klog.V(2).Infof("Updating DNS record %s %s -> %s", recordName, recordType, targetIP) + + // Create changeset and update the record + changeset := rrs.StartChangeset() + + // Remove old records with the same type + for _, record := range existing { + if record.Type() == recordType { + klog.V(4).Infof("Removing existing DNS record %s %s -> %v", recordName, record.Type(), record.Rrdatas()) + changeset.Remove(record) + } + } + + // Add new record + newRecord := rrs.New(recordName, []string{targetIP}, 300, recordType) + changeset.Add(newRecord) + + if err := changeset.Apply(context.Background()); err != nil { + return fmt.Errorf("error applying DNS changeset: %w", err) + } + + klog.V(2).Infof("Updated DNS record %s %s -> %s", recordName, recordType, targetIP) + return nil +} + +// findDNSZone finds the DNS zone that contains the given hostname +func findDNSZone(dnsProvider dnsprovider.Interface, hostname string) (dnsprovider.Zone, error) { + zones, supported := dnsProvider.Zones() + if !supported { + return nil, fmt.Errorf("DNS provider does not support zones") + } + + allZones, err := zones.List() + if err != nil { + return nil, fmt.Errorf("error listing DNS zones: %w", err) + } + + hostname = dnspkg.EnsureDotSuffix(hostname) + + var matches []dnsprovider.Zone + for _, zone := range allZones { + zoneName := dnspkg.EnsureDotSuffix(zone.Name()) + if strings.HasSuffix(hostname, zoneName) { + matches = append(matches, zone) + } + } + + if len(matches) == 0 { + return nil, nil + } + + // Return the most specific match (longest zone name) + bestMatch := matches[0] + for _, match := range matches { + if len(match.Name()) > len(bestMatch.Name()) { + bestMatch = match + } + } + + return bestMatch, nil +} diff --git a/upup/pkg/fi/cloudup/linodetasks/dnsrecord_fitask.go b/upup/pkg/fi/cloudup/linodetasks/dnsrecord_fitask.go new file mode 100644 index 0000000000000..30a5ab6563946 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/dnsrecord_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" +) + +// DNSRecord + +var _ fi.HasLifecycle = &DNSRecord{} + +// GetLifecycle returns the Lifecycle of the object, implementing fi.HasLifecycle +func (o *DNSRecord) GetLifecycle() fi.Lifecycle { + return o.Lifecycle +} + +// SetLifecycle sets the Lifecycle of the object, implementing fi.SetLifecycle +func (o *DNSRecord) SetLifecycle(lifecycle fi.Lifecycle) { + o.Lifecycle = lifecycle +} + +var _ fi.HasName = &DNSRecord{} + +// GetName returns the Name of the object, implementing fi.HasName +func (o *DNSRecord) GetName() *string { + return o.Name +} + +// String is the stringer function for the task, producing readable output using fi.TaskAsString +func (o *DNSRecord) String() string { + return fi.CloudupTaskAsString(o) +} diff --git a/upup/pkg/fi/cloudup/linodetasks/instance.go b/upup/pkg/fi/cloudup/linodetasks/instance.go new file mode 100644 index 0000000000000..a804645b8e918 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/instance.go @@ -0,0 +1,288 @@ +/* +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" + "encoding/base64" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/linode/linodego" + "k8s.io/kops/pkg/truncate" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +// +kops:fitask +type Instance struct { + Name *string + Lifecycle fi.Lifecycle + + Region *string + Type *string + Image *string + Count int + Tags []string + AuthorizedKey *fi.Resource + UserData *fi.Resource +} + +var _ fi.CloudupTask = &Instance{} +var _ fi.CompareWithID = &Instance{} + +var invalidInstanceLabelChars = regexp.MustCompile(`[^a-z0-9._-]+`) + +func (i *Instance) CompareWithID() *string { + return i.Name +} + +func (i *Instance) GetDependencies(tasks map[string]fi.CloudupTask) []fi.CloudupTask { + var deps []fi.CloudupTask + + if i.UserData != nil { + deps = append(deps, fi.FindDependencies(tasks, i.UserData)...) + } + + return deps +} + +func (i *Instance) Find(c *fi.CloudupContext) (*Instance, error) { + cloud := c.T.Cloud.(linode.LinodeCloud) + + instances, err := cloud.Client().ListInstances(c.Context(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) instances: %w", err) + } + + var matched []linodego.Instance + for idx := range instances { + instance := instances[idx] + if !hasAllTags(instance.Tags, i.Tags) { + continue + } + matched = append(matched, instance) + } + + if len(matched) == 0 { + return nil, nil + } + + first := matched[0] + return &Instance{ + Name: i.Name, + Lifecycle: i.Lifecycle, + Region: fi.PtrTo(first.Region), + Type: fi.PtrTo(first.Type), + Image: fi.PtrTo(first.Image), + Count: len(matched), + Tags: append([]string(nil), first.Tags...), + AuthorizedKey: i.AuthorizedKey, + UserData: i.UserData, + }, nil +} + +func (i *Instance) Run(c *fi.CloudupContext) error { + return fi.CloudupDefaultDeltaRunMethod(i, c) +} + +func (_ *Instance) CheckChanges(actual, expected, changes *Instance) error { + if expected.Count < 0 { + return fmt.Errorf("Count cannot be negative") + } + + if actual != nil { + if changes.Name != nil { + return fi.CannotChangeField("Name") + } + if changes.Region != nil { + return fi.CannotChangeField("Region") + } + if changes.Type != nil { + return fi.CannotChangeField("Type") + } + if changes.Image != nil { + return fi.CannotChangeField("Image") + } + } else { + if expected.Name == nil { + return fi.RequiredField("Name") + } + if expected.Region == nil { + return fi.RequiredField("Region") + } + if expected.Type == nil { + return fi.RequiredField("Type") + } + if expected.Image == nil { + return fi.RequiredField("Image") + } + } + + return nil +} + +func (_ *Instance) RenderLinode(t *linode.APITarget, actual, expected, changes *Instance) error { + desiredCount := expected.Count + actualCount := 0 + if actual != nil { + actualCount = actual.Count + } + + if desiredCount < actualCount { + return fmt.Errorf("decreasing Linode (Akamai) instance count from %d to %d is not supported yet", actualCount, desiredCount) + } + if desiredCount == actualCount { + return nil + } + + encodedUserData, err := encodeUserData(expected.UserData) + if err != nil { + return err + } + + var authorizedKeys []string + if expected.AuthorizedKey != nil { + publicKey, err := fi.ResourceAsString(*expected.AuthorizedKey) + if err != nil { + return fmt.Errorf("error rendering SSH public key: %w", err) + } + if trimmed := strings.TrimSpace(publicKey); trimmed != "" { + authorizedKeys = append(authorizedKeys, trimmed) + } + } + + for ordinal := actualCount + 1; ordinal <= desiredCount; ordinal++ { + rootPass, err := generateRootPassword() + if err != nil { + return err + } + + opts := linodego.InstanceCreateOptions{ + Region: fi.ValueOf(expected.Region), + Type: fi.ValueOf(expected.Type), + Label: makeInstanceLabel(fi.ValueOf(expected.Name), ordinal), + RootPass: rootPass, + AuthorizedKeys: authorizedKeys, + Image: fi.ValueOf(expected.Image), + PrivateIP: true, + Tags: expected.Tags, + } + if encodedUserData != "" { + opts.Metadata = &linodego.InstanceMetadataOptions{UserData: encodedUserData} + } + + if _, err := t.Cloud.Client().CreateInstance(context.Background(), opts); err != nil { + return fmt.Errorf("error creating Linode (Akamai) instance %q: %w", opts.Label, err) + } + } + + return nil +} + +func hasAllTags(actual, expected []string) bool { + for _, tag := range expected { + if !slices.Contains(actual, tag) { + return false + } + } + return true +} + +func encodeUserData(userData *fi.Resource) (string, error) { + if userData == nil { + return "", nil + } + bytes, err := fi.ResourceAsBytes(*userData) + if err != nil { + return "", fmt.Errorf("error rendering user-data: %w", err) + } + if len(bytes) == 0 { + return "", nil + } + + return base64.StdEncoding.EncodeToString(bytes), nil +} + +func makeInstanceLabel(base string, ordinal int) string { + clean := sanitizeLabelPart(base) + if clean == "" { + clean = "kops-node" + } + + suffix := fmt.Sprintf("-%d", ordinal) + maxBaseLength := max(64-len(suffix), 8) + + clean = truncate.TruncateString(clean, truncate.TruncateStringOptions{MaxLength: maxBaseLength}) + clean = strings.Trim(clean, "-_.") + if clean == "" { + clean = "kops-node" + } + + // Insert ordinal before the first dot to ensure proper DNS zone matching + // e.g., "control-plane-us-ord.masters.cluster.example.com" becomes + // "control-plane-us-ord-1.masters.cluster.example.com" instead of + // "control-plane-us-ord.masters.cluster.example.com-1" + dotIndex := strings.IndexByte(clean, '.') + if dotIndex != -1 { + return clean[:dotIndex] + suffix + clean[dotIndex:] + } + + return clean + suffix +} + +func sanitizeLabel(s string, re *regexp.Regexp, trimSet string) string { + s = strings.ToLower(s) + s = re.ReplaceAllString(s, "-") + s = collapseAdjacentSeparators(s) + s = strings.Trim(s, trimSet) + return s +} + +func sanitizeLabelPart(s string) string { + return sanitizeLabel(s, invalidInstanceLabelChars, "-_.") +} + +func collapseAdjacentSeparators(s string) string { + if s == "" { + return s + } + + var b strings.Builder + b.Grow(len(s)) + + var previous rune + for _, r := range s { + if (r == '-' || r == '_' || r == '.') && r == previous { + continue + } + b.WriteRune(r) + previous = r + } + + return b.String() +} + +func generateRootPassword() (string, error) { + secret, err := fi.CreateSecret() + if err != nil { + return "", fmt.Errorf("error generating root password: %w", err) + } + return string(secret.Data), nil +} diff --git a/upup/pkg/fi/cloudup/linodetasks/instance_fitask.go b/upup/pkg/fi/cloudup/linodetasks/instance_fitask.go new file mode 100644 index 0000000000000..942b9ad6c1aad --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/instance_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" +) + +// Instance + +var _ fi.HasLifecycle = &Instance{} + +// GetLifecycle returns the Lifecycle of the object, implementing fi.HasLifecycle +func (o *Instance) GetLifecycle() fi.Lifecycle { + return o.Lifecycle +} + +// SetLifecycle sets the Lifecycle of the object, implementing fi.SetLifecycle +func (o *Instance) SetLifecycle(lifecycle fi.Lifecycle) { + o.Lifecycle = lifecycle +} + +var _ fi.HasName = &Instance{} + +// GetName returns the Name of the object, implementing fi.HasName +func (o *Instance) GetName() *string { + return o.Name +} + +// String is the stringer function for the task, producing readable output using fi.TaskAsString +func (o *Instance) String() string { + return fi.CloudupTaskAsString(o) +} diff --git a/upup/pkg/fi/cloudup/linodetasks/instance_test.go b/upup/pkg/fi/cloudup/linodetasks/instance_test.go new file mode 100644 index 0000000000000..339f02d923550 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/instance_test.go @@ -0,0 +1,172 @@ +/* +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 ( + "encoding/base64" + "errors" + "regexp" + "strings" + "testing" + + "github.com/linode/linodego" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +func TestInstanceFindMatch(t *testing.T) { + client := &linode.MockLinodeClient{ + ListInstancesResponse: []linodego.Instance{ + {ID: 1001, Label: "nodes-example-1", Region: "us-east", Type: "g6-standard-2", Image: "linode/ubuntu24.04", Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-group:nodes-us-east"}}, + {ID: 1002, Label: "nodes-example-2", Region: "us-east", Type: "g6-standard-2", Image: "linode/ubuntu24.04", Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-group:nodes-us-east"}}, + {ID: 2001, Label: "other-ig", Region: "us-east", Type: "g6-standard-2", Image: "linode/ubuntu24.04", Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-group:control-plane-us-east"}}, + }, + } + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &Instance{ + Name: fi.PtrTo("nodes-us-east.example.k8s.local"), + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-group:nodes-us-east"}, + Count: 2, + } + + actual, err := task.Find(ctx) + if err != nil { + t.Fatalf("Find returned error: %v", err) + } + if actual == nil { + t.Fatalf("expected to find matching instances") + } + if got, want := actual.Count, 2; got != want { + t.Fatalf("unexpected count: got %d, want %d", got, want) + } + if got, want := fi.ValueOf(actual.Region), "us-east"; got != want { + t.Fatalf("unexpected region: got %q, want %q", got, want) + } + if got, want := fi.ValueOf(actual.Type), "g6-standard-2"; got != want { + t.Fatalf("unexpected type: got %q, want %q", got, want) + } +} + +func TestInstanceFindListError(t *testing.T) { + client := &linode.MockLinodeClient{ListInstancesError: errors.New("api unavailable")} + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &Instance{Tags: []string{"kops.k8s.io/cluster:example.k8s.local"}} + _, err := task.Find(ctx) + if err == nil { + t.Fatalf("expected list error") + } + if !strings.Contains(err.Error(), "error listing Linode (Akamai) instances") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInstanceRenderLinodeCreate(t *testing.T) { + client := &linode.MockLinodeClient{} + target := linode.NewAPITarget(&linode.MockLinodeCloud{Client_: client}) + + userData := fi.Resource(fi.NewStringResource("#cloud-config\nruncmd:\n - echo hello\n")) + publicKey := fi.Resource(fi.NewStringResource(testOpenSSHPublicKey)) + expected := &Instance{ + Name: fi.PtrTo("nodes.us-east.example.k8s.local"), + Region: fi.PtrTo("us-east"), + Type: fi.PtrTo("g6-standard-2"), + Image: fi.PtrTo("linode/ubuntu24.04"), + Count: 2, + Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/instance-group:nodes-us-east", "kops.k8s.io/instance-role:Node"}, + AuthorizedKey: &publicKey, + UserData: &userData, + } + + if err := (&Instance{}).RenderLinode(target, nil, expected, nil); err != nil { + t.Fatalf("RenderLinode returned error: %v", err) + } + + if got, want := client.CreateInstanceCalls, 2; got != want { + t.Fatalf("unexpected create calls: got %d, want %d", got, want) + } + if got, want := client.LastCreateInstanceOpts.Region, "us-east"; got != want { + t.Fatalf("unexpected region: got %q, want %q", got, want) + } + if got, want := client.LastCreateInstanceOpts.Type, "g6-standard-2"; got != want { + t.Fatalf("unexpected type: got %q, want %q", got, want) + } + if got, want := client.LastCreateInstanceOpts.Image, "linode/ubuntu24.04"; got != want { + t.Fatalf("unexpected image: got %q, want %q", got, want) + } + if got := client.LastCreateInstanceOpts.RootPass; got == "" { + t.Fatalf("expected root password to be populated") + } + if got, want := client.LastCreateInstanceOpts.PrivateIP, true; got != want { + t.Fatalf("unexpected private IP setting: got %t, want %t", got, want) + } + if got, want := len(client.LastCreateInstanceOpts.AuthorizedKeys), 1; got != want { + t.Fatalf("unexpected authorized key count: got %d, want %d", got, want) + } + if got, want := client.LastCreateInstanceOpts.AuthorizedKeys[0], testOpenSSHPublicKey; got != want { + t.Fatalf("unexpected authorized key: got %q, want %q", got, want) + } + if client.LastCreateInstanceOpts.Metadata == nil { + t.Fatalf("expected metadata to be configured") + } + decodedUserData, err := base64.StdEncoding.DecodeString(client.LastCreateInstanceOpts.Metadata.UserData) + if err != nil { + t.Fatalf("failed to decode metadata user data: %v", err) + } + if got, want := string(decodedUserData), "#cloud-config\nruncmd:\n - echo hello\n"; got != want { + t.Fatalf("unexpected user data payload: got %q, want %q", got, want) + } +} + +func TestInstanceRenderLinodeScaleDownNotSupported(t *testing.T) { + client := &linode.MockLinodeClient{} + target := linode.NewAPITarget(&linode.MockLinodeCloud{Client_: client}) + + actual := &Instance{Count: 2} + expected := &Instance{ + Name: fi.PtrTo("nodes.example.k8s.local"), + Region: fi.PtrTo("us-east"), + Type: fi.PtrTo("g6-standard-2"), + Image: fi.PtrTo("linode/ubuntu24.04"), + Count: 1, + } + + err := (&Instance{}).RenderLinode(target, actual, expected, nil) + if err == nil { + t.Fatalf("expected scale-down error") + } + if !strings.Contains(err.Error(), "decreasing Linode (Akamai) instance count") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMakeInstanceLabel(t *testing.T) { + label := makeInstanceLabel("nodes__us..east@@example.k8s.local", 17) + + if len(label) > 64 { + t.Fatalf("label too long: %d characters", len(label)) + } + if ok, err := regexp.MatchString(`^[a-z0-9][a-z0-9._-]*[a-z0-9]$`, label); err != nil || !ok { + t.Fatalf("label does not satisfy Linode format: %q", label) + } + if strings.Contains(label, "--") || strings.Contains(label, "__") || strings.Contains(label, "..") { + t.Fatalf("label should not contain repeated separators: %q", label) + } +} 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) +} diff --git a/upup/pkg/fi/cloudup/linodetasks/sshkey.go b/upup/pkg/fi/cloudup/linodetasks/sshkey.go new file mode 100644 index 0000000000000..dc0a3dc0cf475 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/sshkey.go @@ -0,0 +1,142 @@ +/* +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" + "strings" + + "github.com/linode/linodego" + "k8s.io/klog/v2" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +// +kops:fitask +type SSHKey struct { + ID *int + Name *string + Lifecycle fi.Lifecycle + PublicKey *fi.Resource +} + +var _ fi.CompareWithID = &SSHKey{} +var _ fi.CloudupTask = &SSHKey{} + +func (s *SSHKey) CompareWithID() *string { + return s.Name +} + +func (s *SSHKey) Find(c *fi.CloudupContext) (*SSHKey, error) { + cloud := c.T.Cloud.(linode.LinodeCloud) + name := fi.ValueOf(s.Name) + if name == "" { + return nil, fmt.Errorf("SSHKey.Name is required") + } + + keys, err := cloud.Client().ListSSHKeys(c.Context(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) SSH keys: %w", err) + } + + var matched *linodego.SSHKey + for i := range keys { + key := &keys[i] + if key.Label != name { + continue + } + + if matched != nil { + return nil, fmt.Errorf("found multiple SSH keys named %q", name) + } + matched = key + } + + if matched == nil { + return nil, nil + } + + actual := &SSHKey{ + ID: fi.PtrTo(matched.ID), + Name: fi.PtrTo(matched.Label), + Lifecycle: s.Lifecycle, + } + + if s.PublicKey != nil { + expectedPublicKey, err := fi.ResourceAsString(*s.PublicKey) + if err != nil { + return nil, fmt.Errorf("error rendering SSH key data: %w", err) + } + + if strings.TrimSpace(expectedPublicKey) != strings.TrimSpace(matched.SSHKey) { + return nil, fmt.Errorf("found SSH key %q in Linode (Akamai), but public key data did not match", name) + } + + // Avoid spurious changes. + actual.PublicKey = s.PublicKey + } + + return actual, nil +} + +func (s *SSHKey) Run(c *fi.CloudupContext) error { + return fi.CloudupDefaultDeltaRunMethod(s, c) +} + +func (_ *SSHKey) CheckChanges(actual, expected, changes *SSHKey) error { + if actual != nil { + if changes.Name != nil { + return fi.CannotChangeField("Name") + } + } + + return nil +} + +func (*SSHKey) RenderLinode(t *linode.APITarget, actual, expected, changes *SSHKey) error { + if actual != nil { + return nil + } + + name := fi.ValueOf(expected.Name) + if name == "" { + return fi.RequiredField("Name") + } + + if expected.PublicKey == nil { + return fi.RequiredField("PublicKey") + } + + publicKey, err := fi.ResourceAsString(*expected.PublicKey) + if err != nil { + return fmt.Errorf("error rendering SSH key data: %w", err) + } + + created, err := t.Cloud.Client().CreateSSHKey(context.Background(), linodego.SSHKeyCreateOptions{ + Label: name, + SSHKey: strings.TrimSpace(publicKey), + }) + if err != nil { + return fmt.Errorf("error creating Linode (Akamai) SSH key %q: %w", name, err) + } + + expected.ID = fi.PtrTo(created.ID) + klog.V(2).Infof("Created Linode (Akamai) SSH key %q (id=%d)", created.Label, created.ID) + + return nil +} diff --git a/upup/pkg/fi/cloudup/linodetasks/sshkey_fitask.go b/upup/pkg/fi/cloudup/linodetasks/sshkey_fitask.go new file mode 100644 index 0000000000000..822c415eb7813 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/sshkey_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" +) + +// SSHKey + +var _ fi.HasLifecycle = &SSHKey{} + +// GetLifecycle returns the Lifecycle of the object, implementing fi.HasLifecycle +func (o *SSHKey) GetLifecycle() fi.Lifecycle { + return o.Lifecycle +} + +// SetLifecycle sets the Lifecycle of the object, implementing fi.SetLifecycle +func (o *SSHKey) SetLifecycle(lifecycle fi.Lifecycle) { + o.Lifecycle = lifecycle +} + +var _ fi.HasName = &SSHKey{} + +// GetName returns the Name of the object, implementing fi.HasName +func (o *SSHKey) GetName() *string { + return o.Name +} + +// String is the stringer function for the task, producing readable output using fi.TaskAsString +func (o *SSHKey) String() string { + return fi.CloudupTaskAsString(o) +} diff --git a/upup/pkg/fi/cloudup/linodetasks/sshkey_test.go b/upup/pkg/fi/cloudup/linodetasks/sshkey_test.go new file mode 100644 index 0000000000000..11189027f9d09 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/sshkey_test.go @@ -0,0 +1,175 @@ +/* +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 ( + "errors" + "strings" + "testing" + + "github.com/linode/linodego" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +const testOpenSSHPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCySdqIU+FhCWl3BNrAvPaOe5VfL2aCARUWwy91ZP+T7LBwFa9lhdttfjp/VX1D1/PVwntn2EhN079m8c2kfdmiZ/iCHqrLyIGSd+BOiCz0lT47znvANSfxYjLUuKrWWWeaXqerJkOsAD4PHchRLbZGPdbfoBKwtb/WT4GMRQmb9vmiaZYjsfdPPM9KkWI9ECoWFGjGehA8D+iYIPR711kRacb1xdYmnjHqxAZHFsb5L8wDWIeAyhy49cBD+lbzTiioq2xWLorXuFmXh6Do89PgzvHeyCLY6816f/kCX6wIFts8A2eaEHFL4rAOsuh6qHmSxGCR9peSyuRW8DxV725x justin@test" + +func newTestPublicKeyResource() *fi.Resource { + resource := fi.Resource(fi.NewStringResource(testOpenSSHPublicKey)) + return &resource +} + +func TestSSHKeyFindMatch(t *testing.T) { + publicKey := newTestPublicKeyResource() + client := &linode.MockLinodeClient{ + ListSSHKeysResponse: []linodego.SSHKey{{ + ID: 123, + Label: "kubernetes.example.k8s.local-1234", + SSHKey: testOpenSSHPublicKey, + }}, + } + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &SSHKey{ + Name: fi.PtrTo("kubernetes.example.k8s.local-1234"), + PublicKey: publicKey, + Lifecycle: fi.LifecycleSync, + } + + actual, err := task.Find(ctx) + if err != nil { + t.Fatalf("Find returned error: %v", err) + } + if actual == nil { + t.Fatalf("expected to find SSH key") + } + if got, want := fi.ValueOf(actual.ID), 123; got != want { + t.Fatalf("unexpected ID: got %d, want %d", got, want) + } +} + +func TestSSHKeyFindDuplicate(t *testing.T) { + client := &linode.MockLinodeClient{ + ListSSHKeysResponse: []linodego.SSHKey{ + {ID: 1, Label: "kubernetes.example.k8s.local-1234", SSHKey: "ssh-rsa AAAA test"}, + {ID: 2, Label: "kubernetes.example.k8s.local-1234", SSHKey: "ssh-rsa AAAA test"}, + }, + } + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &SSHKey{Name: fi.PtrTo("kubernetes.example.k8s.local-1234")} + _, err := task.Find(ctx) + if err == nil { + t.Fatalf("expected duplicate name error") + } + if !strings.Contains(err.Error(), "found multiple SSH keys named") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSSHKeyFindPublicKeyMismatch(t *testing.T) { + publicKey := newTestPublicKeyResource() + client := &linode.MockLinodeClient{ + ListSSHKeysResponse: []linodego.SSHKey{{ + ID: 123, + Label: "kubernetes.example.k8s.local-1234", + SSHKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDbadkey", + }}, + } + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &SSHKey{Name: fi.PtrTo("kubernetes.example.k8s.local-1234"), PublicKey: publicKey} + _, err := task.Find(ctx) + if err == nil { + t.Fatalf("expected mismatch error") + } + if !strings.Contains(err.Error(), "public key data did not match") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSSHKeyFindListError(t *testing.T) { + client := &linode.MockLinodeClient{ListSSHKeysError: errors.New("api unavailable")} + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &SSHKey{Name: fi.PtrTo("kubernetes.example.k8s.local-1234")} + _, err := task.Find(ctx) + if err == nil { + t.Fatalf("expected list error") + } + if !strings.Contains(err.Error(), "error listing Linode (Akamai) SSH keys") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSSHKeyRenderLinodeCreate(t *testing.T) { + publicKey := newTestPublicKeyResource() + client := &linode.MockLinodeClient{CreateSSHKeyResponse: &linodego.SSHKey{ID: 42, Label: "kubernetes.example.k8s.local-1234"}} + cloud := &linode.MockLinodeCloud{Client_: client} + target := linode.NewAPITarget(cloud) + + expected := &SSHKey{Name: fi.PtrTo("kubernetes.example.k8s.local-1234"), PublicKey: publicKey} + err := (&SSHKey{}).RenderLinode(target, nil, expected, nil) + if err != nil { + t.Fatalf("RenderLinode returned error: %v", err) + } + if got, want := client.CreateSSHKeyCalls, 1; got != want { + t.Fatalf("unexpected create calls: got %d, want %d", got, want) + } + if got, want := client.LastCreateSSHKeyOpts.Label, "kubernetes.example.k8s.local-1234"; got != want { + t.Fatalf("unexpected create label: got %q, want %q", got, want) + } + if fi.ValueOf(expected.ID) != 42 { + t.Fatalf("expected task ID to be populated from create response") + } +} + +func TestSSHKeyRenderLinodeNoopWhenActualExists(t *testing.T) { + publicKey := newTestPublicKeyResource() + client := &linode.MockLinodeClient{} + cloud := &linode.MockLinodeCloud{Client_: client} + target := linode.NewAPITarget(cloud) + + actual := &SSHKey{Name: fi.PtrTo("kubernetes.example.k8s.local-1234"), ID: fi.PtrTo(11)} + expected := &SSHKey{Name: fi.PtrTo("kubernetes.example.k8s.local-1234"), PublicKey: publicKey} + err := (&SSHKey{}).RenderLinode(target, actual, expected, nil) + if err != nil { + t.Fatalf("RenderLinode returned error: %v", err) + } + if got := client.CreateSSHKeyCalls; got != 0 { + t.Fatalf("unexpected create calls: got %d, want 0", got) + } +} + +func TestSSHKeyRenderLinodeRequiresPublicKey(t *testing.T) { + client := &linode.MockLinodeClient{} + cloud := &linode.MockLinodeCloud{Client_: client} + target := linode.NewAPITarget(cloud) + + expected := &SSHKey{Name: fi.PtrTo("kubernetes.example.k8s.local-1234")} + err := (&SSHKey{}).RenderLinode(target, nil, expected, nil) + if err == nil { + t.Fatalf("expected missing PublicKey error") + } + if !strings.Contains(err.Error(), "PublicKey") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/upup/pkg/fi/cloudup/linodetasks/volume.go b/upup/pkg/fi/cloudup/linodetasks/volume.go new file mode 100644 index 0000000000000..9c2aa150545c6 --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/volume.go @@ -0,0 +1,174 @@ +/* +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" + "regexp" + "slices" + "strconv" + + "github.com/linode/linodego" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +const maxLinodeVolumeLabelLength = 32 + +// +kops:fitask +type Volume struct { + Name *string + ID *int + Lifecycle fi.Lifecycle + + Region *string + SizeGB *int64 + Tags []string +} + +var _ fi.CloudupTask = &Volume{} +var _ fi.CompareWithID = &Volume{} + +var invalidVolumeLabelChars = regexp.MustCompile(`[^a-z0-9_-]+`) + +func (v *Volume) CompareWithID() *string { + if v.ID == nil { + return nil + } + id := strconv.Itoa(fi.ValueOf(v.ID)) + return fi.PtrTo(id) +} + +func (v *Volume) Find(c *fi.CloudupContext) (*Volume, error) { + cloud := c.T.Cloud.(linode.LinodeCloud) + + volumes, err := cloud.Client().ListVolumes(c.Context(), nil) + if err != nil { + return nil, fmt.Errorf("error listing Linode (Akamai) volumes: %w", err) + } + + label := normalizedVolumeLabel(fi.ValueOf(v.Name)) + var found *linodego.Volume + for i := range volumes { + volume := &volumes[i] + if volume.Label != label { + continue + } + if !hasAllTags(volume.Tags, v.Tags) { + continue + } + if found != nil { + return nil, fmt.Errorf("found multiple Linode (Akamai) volumes named %q with matching tags", label) + } + found = volume + } + + if found == nil { + return nil, nil + } + + actual := &Volume{ + // Preserve desired task identity to avoid a synthetic Name change when + // the cloud label is normalized from the desired dotted etcd name. + Name: v.Name, + ID: fi.PtrTo(found.ID), + Lifecycle: v.Lifecycle, + Region: fi.PtrTo(found.Region), + SizeGB: fi.PtrTo(int64(found.Size)), + Tags: slices.Clone(found.Tags), + } + v.ID = actual.ID + + return actual, nil +} + +func (v *Volume) Run(c *fi.CloudupContext) error { + return fi.CloudupDefaultDeltaRunMethod(v, c) +} + +func (_ *Volume) CheckChanges(a, e, changes *Volume) 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") + } + if changes.SizeGB != nil { + return fi.CannotChangeField("SizeGB") + } + } else { + if e.Name == nil { + return fi.RequiredField("Name") + } + if e.Region == nil { + return fi.RequiredField("Region") + } + if e.SizeGB == nil { + return fi.RequiredField("SizeGB") + } + if fi.ValueOf(e.SizeGB) < 10 { + return fmt.Errorf("Linode (Akamai) volume size must be at least 10GiB") + } + } + + return nil +} + +func (_ *Volume) RenderLinode(t *linode.APITarget, a, e, changes *Volume) error { + if a != nil { + // We currently only support create-once semantics for etcd volumes. + return nil + } + + _, err := t.Cloud.Client().CreateVolume(context.Background(), linodego.VolumeCreateOptions{ + Label: normalizedVolumeLabel(fi.ValueOf(e.Name)), + Region: fi.ValueOf(e.Region), + Size: int(fi.ValueOf(e.SizeGB)), + Tags: slices.Clone(e.Tags), + }) + if err != nil { + return fmt.Errorf("error creating Linode (Akamai) volume %q: %w", fi.ValueOf(e.Name), err) + } + + return nil +} + +func normalizedVolumeLabel(name string) string { + clean := sanitizeVolumeLabelPart(name) + if clean == "" { + clean = "kops-etcd" + } + + if len(clean) > maxLinodeVolumeLabelLength { + clean = clean[:maxLinodeVolumeLabelLength] + } + clean = sanitizeVolumeLabelPart(clean) + if clean == "" { + clean = "kops-etcd" + } + + return clean +} + +func sanitizeVolumeLabelPart(s string) string { + return sanitizeLabel(s, invalidVolumeLabelChars, "-_") +} diff --git a/upup/pkg/fi/cloudup/linodetasks/volume_fitask.go b/upup/pkg/fi/cloudup/linodetasks/volume_fitask.go new file mode 100644 index 0000000000000..38c935e53b1ec --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/volume_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" +) + +// Volume + +var _ fi.HasLifecycle = &Volume{} + +// GetLifecycle returns the Lifecycle of the object, implementing fi.HasLifecycle +func (o *Volume) GetLifecycle() fi.Lifecycle { + return o.Lifecycle +} + +// SetLifecycle sets the Lifecycle of the object, implementing fi.SetLifecycle +func (o *Volume) SetLifecycle(lifecycle fi.Lifecycle) { + o.Lifecycle = lifecycle +} + +var _ fi.HasName = &Volume{} + +// GetName returns the Name of the object, implementing fi.HasName +func (o *Volume) GetName() *string { + return o.Name +} + +// String is the stringer function for the task, producing readable output using fi.TaskAsString +func (o *Volume) String() string { + return fi.CloudupTaskAsString(o) +} diff --git a/upup/pkg/fi/cloudup/linodetasks/volume_test.go b/upup/pkg/fi/cloudup/linodetasks/volume_test.go new file mode 100644 index 0000000000000..ebaae32a19d4f --- /dev/null +++ b/upup/pkg/fi/cloudup/linodetasks/volume_test.go @@ -0,0 +1,143 @@ +/* +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 ( + "errors" + "strings" + "testing" + + "github.com/linode/linodego" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" +) + +func TestVolumeFindMatch(t *testing.T) { + client := &linode.MockLinodeClient{ + ListVolumesResponse: []linodego.Volume{ + {ID: 101, Label: "cp-0-etcd-main-example-k8s-local", Region: "us-ord", Size: 20, Tags: []string{"kops.k8s.io/cluster:example.k8s.local", "kops.k8s.io/etcd:main", "kops.k8s.io/instance-group:control-plane-us-ord"}}, + {ID: 102, Label: "other", Region: "us-ord", Size: 20, Tags: []string{"kops.k8s.io/cluster:other.k8s.local"}}, + }, + } + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &Volume{ + Name: fi.PtrTo("cp-0.etcd-main.example.k8s.local"), + Region: fi.PtrTo("us-ord"), + SizeGB: fi.PtrTo(int64(20)), + Tags: []string{ + "kops.k8s.io/cluster:example.k8s.local", + "kops.k8s.io/etcd:main", + "kops.k8s.io/instance-group:control-plane-us-ord", + }, + } + + actual, err := task.Find(ctx) + if err != nil { + t.Fatalf("Find returned error: %v", err) + } + if actual == nil { + t.Fatalf("expected to find matching volume") + } + if got, want := fi.ValueOf(actual.ID), 101; got != want { + t.Fatalf("unexpected volume ID: got %d, want %d", got, want) + } + if got, want := fi.ValueOf(actual.Region), "us-ord"; got != want { + t.Fatalf("unexpected region: got %q, want %q", got, want) + } + if got, want := fi.ValueOf(actual.SizeGB), int64(20); got != want { + t.Fatalf("unexpected size: got %d, want %d", got, want) + } + if got, want := fi.ValueOf(actual.Name), fi.ValueOf(task.Name); got != want { + t.Fatalf("expected task identity name to be preserved: got %q, want %q", got, want) + } + if got, want := fi.ValueOf(task.ID), 101; got != want { + t.Fatalf("expected task ID to be propagated after Find: got %d, want %d", got, want) + } +} + +func TestVolumeFindListError(t *testing.T) { + client := &linode.MockLinodeClient{ListVolumesError: errors.New("api unavailable")} + cloud := &linode.MockLinodeCloud{Client_: client} + ctx := newTestCloudupContext(t, cloud) + + task := &Volume{Name: fi.PtrTo("cp-0.etcd-main.example.k8s.local")} + _, err := task.Find(ctx) + if err == nil { + t.Fatalf("expected list error") + } + if !strings.Contains(err.Error(), "error listing Linode (Akamai) volumes") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestVolumeRenderLinodeCreate(t *testing.T) { + client := &linode.MockLinodeClient{} + target := linode.NewAPITarget(&linode.MockLinodeCloud{Client_: client}) + + expected := &Volume{ + Name: fi.PtrTo("cp-0.etcd-main.example.k8s.local"), + Region: fi.PtrTo("us-ord"), + SizeGB: fi.PtrTo(int64(20)), + Tags: []string{ + "kops.k8s.io/cluster:example.k8s.local", + "kops.k8s.io/etcd:main", + "kops.k8s.io/instance-group:control-plane-us-ord", + }, + } + + if err := (&Volume{}).RenderLinode(target, nil, expected, nil); err != nil { + t.Fatalf("RenderLinode returned error: %v", err) + } + + if got, want := client.CreateVolumeCalls, 1; got != want { + t.Fatalf("unexpected create calls: got %d, want %d", got, want) + } + if got, want := client.LastCreateVolumeOpts.Region, "us-ord"; got != want { + t.Fatalf("unexpected region: got %q, want %q", got, want) + } + if got, want := client.LastCreateVolumeOpts.Size, 20; got != want { + t.Fatalf("unexpected size: got %d, want %d", got, want) + } + if got, want := client.LastCreateVolumeOpts.Label, "cp-0-etcd-main-example-k8s-local"; got != want { + t.Fatalf("unexpected label: got %q, want %q", got, want) + } +} + +func TestNormalizedVolumeLabel(t *testing.T) { + longName := "Etcd MAIN volume for Control Plane 0 in very-long-cluster-name.with.many.parts.and.characters.example.k8s.local" + label := normalizedVolumeLabel(longName) + + if len(label) > maxLinodeVolumeLabelLength { + t.Fatalf("label too long: %d", len(label)) + } + if label == "" { + t.Fatalf("label should not be empty") + } + if strings.Contains(label, " ") { + t.Fatalf("label should not contain spaces: %q", label) + } + if strings.Contains(label, ".") { + t.Fatalf("label should not contain dots: %q", label) + } + + regression := normalizedVolumeLabel("d.etcd-events.test-linode.k8s.local") + if got, want := regression, "d-etcd-events-test-linode-k8s-lo"; got != want { + t.Fatalf("unexpected normalized label for regression case: got %q, want %q", got, want) + } +} diff --git a/upup/pkg/fi/cloudup/template_functions.go b/upup/pkg/fi/cloudup/template_functions.go index 54ccfb2ff2239..1f49446535441 100644 --- a/upup/pkg/fi/cloudup/template_functions.go +++ b/upup/pkg/fi/cloudup/template_functions.go @@ -66,6 +66,7 @@ import ( "k8s.io/kops/upup/pkg/fi/cloudup/gce" gcetpm "k8s.io/kops/upup/pkg/fi/cloudup/gce/tpm" "k8s.io/kops/upup/pkg/fi/cloudup/hetzner" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" "k8s.io/kops/upup/pkg/fi/cloudup/openstack" "k8s.io/kops/upup/pkg/fi/cloudup/scaleway" "k8s.io/kops/util/pkg/env" @@ -191,6 +192,13 @@ func (tf *TemplateFunctions) AddTo(dest template.FuncMap, secretStore fi.SecretS return cluster.Name } + dest["LINODE_TOKEN"] = func() string { + return os.Getenv("LINODE_TOKEN") + } + dest["LINODE_REGION"] = func() string { + return tf.cloud.Region() + } + dest["OPENSTACK_CONF"] = func() string { lines := openstack.MakeCloudConfig(cluster.Spec.CloudProvider.Openstack) return "[global]\n" + strings.Join(lines, "\n") + "\n" @@ -651,6 +659,8 @@ func (tf *TemplateFunctions) DNSControllerArgv() ([]string, error) { argv = append(argv, "--dns=openstack-designate") case kops.CloudProviderScaleway: argv = append(argv, "--dns=scaleway") + case kops.CloudProviderLinode: + argv = append(argv, "--dns=linode") default: return nil, fmt.Errorf("unhandled cloudprovider %q", cluster.GetCloudProvider()) @@ -791,6 +801,9 @@ func (tf *TemplateFunctions) KopsControllerConfig() (string, error) { ClusterName: tf.ClusterName(), } + case kops.CloudProviderLinode: + config.Server.Provider.Linode = &linode.LinodeVerifierOptions{} + case kops.CloudProviderMetal: // Use crypto public/private keys for Metal config.Server.PKI = &pkibootstrap.Options{} diff --git a/upup/pkg/fi/cloudup/utils.go b/upup/pkg/fi/cloudup/utils.go index 94a7e626ce71f..d31754ba8a6e7 100644 --- a/upup/pkg/fi/cloudup/utils.go +++ b/upup/pkg/fi/cloudup/utils.go @@ -31,6 +31,7 @@ import ( "k8s.io/kops/upup/pkg/fi/cloudup/do" "k8s.io/kops/upup/pkg/fi/cloudup/gce" "k8s.io/kops/upup/pkg/fi/cloudup/hetzner" + "k8s.io/kops/upup/pkg/fi/cloudup/linode" "k8s.io/kops/upup/pkg/fi/cloudup/metal" "k8s.io/kops/upup/pkg/fi/cloudup/openstack" "k8s.io/kops/upup/pkg/fi/cloudup/scaleway" @@ -203,6 +204,25 @@ func BuildCloud(cluster *kops.Cluster) (fi.Cloud, error) { return nil, fmt.Errorf("error initializing Metal cloud: %w", err) } cloud = metalCloud + + case kops.CloudProviderLinode: + for _, subnet := range cluster.Spec.Networking.Subnets { + if subnet.Region != "" { + region = subnet.Region + break + } + } + if region == "" { + return nil, fmt.Errorf("on Linode (Akamai), subnets must include Regions") + } + + linodeCloud, err := linode.NewCloud(region) + if err != nil { + return nil, fmt.Errorf("error initializing Linode (Akamai) cloud: %w", err) + } + + cloud = linodeCloud + default: return nil, fmt.Errorf("unknown CloudProvider %q", cluster.GetCloudProvider()) } diff --git a/upup/pkg/fi/cloudup/utils_test.go b/upup/pkg/fi/cloudup/utils_test.go new file mode 100644 index 0000000000000..5e6b15a04d95f --- /dev/null +++ b/upup/pkg/fi/cloudup/utils_test.go @@ -0,0 +1,78 @@ +/* +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 cloudup + +import ( + "strings" + "testing" + + "k8s.io/kops/pkg/apis/kops" +) + +func newLinodeTestCluster(region string) *kops.Cluster { + return &kops.Cluster{ + Spec: kops.ClusterSpec{ + CloudProvider: kops.CloudProviderSpec{ + Linode: &kops.LinodeSpec{}, + }, + Networking: kops.NetworkingSpec{ + Subnets: []kops.ClusterSubnetSpec{ + {Region: region}, + }, + }, + }, + } +} + +func TestBuildCloudLinodeRequiresSubnetRegion(t *testing.T) { + t.Setenv("LINODE_TOKEN", "test-token") + + _, err := BuildCloud(newLinodeTestCluster("")) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "subnets must include Regions") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildCloudLinodeRequiresToken(t *testing.T) { + t.Setenv("LINODE_TOKEN", "") + + _, err := BuildCloud(newLinodeTestCluster("us-east")) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "LINODE_TOKEN is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildCloudLinodeSuccess(t *testing.T) { + t.Setenv("LINODE_TOKEN", "test-token") + + cloud, err := BuildCloud(newLinodeTestCluster("us-east")) + if err != nil { + t.Fatalf("BuildCloud 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(), "us-east"; got != want { + t.Fatalf("region mismatch: got %q, want %q", got, want) + } +}