/* Copyright 2019 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 openstacktasks import ( "context" "fmt" "strconv" "strings" l3floatingip "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/ports" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/attachinterfaces" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" "k8s.io/klog/v2" "k8s.io/kops/pkg/truncate" "k8s.io/kops/pkg/wellknownservices" "k8s.io/kops/upup/pkg/fi" "k8s.io/kops/upup/pkg/fi/cloudup/openstack" ) // +kops:fitask type Instance struct { ID *string Name *string GroupName *string Port *Port Region *string Flavor *string Image *string SSHKey *string ServerGroup *ServerGroup Role *string UserData fi.Resource Metadata map[string]string AvailabilityZone *string SecurityGroups []string FloatingIP *FloatingIP ConfigDrive *bool Status *string Lifecycle fi.Lifecycle // 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 = &Instance{} _ fi.HasAddress = &Instance{} _ fi.CloudupHasDependencies = &Instance{} ) // Constants for truncating Tags const MAX_TAG_LENGTH_OPENSTACK = 60 var TRUNCATE_OPT = truncate.TruncateStringOptions{ MaxLength: MAX_TAG_LENGTH_OPENSTACK, AlwaysAddHash: false, HashLength: 6, } // GetDependencies returns the dependencies of the Instance task func (e *Instance) GetDependencies(tasks map[string]fi.CloudupTask) []fi.CloudupTask { var deps []fi.CloudupTask for _, task := range tasks { if _, ok := task.(*ServerGroup); ok { deps = append(deps, task) } if _, ok := task.(*Port); ok { deps = append(deps, task) } if _, ok := task.(*FloatingIP); ok { deps = append(deps, task) } } if e.UserData != nil { deps = append(deps, fi.FindDependencies(tasks, e.UserData)...) } return deps } var _ fi.CompareWithID = &Instance{} func (e *Instance) CompareWithID() *string { return e.ID } // GetWellKnownServices implements fi.HasAddress::GetWellKnownServices. // It indicates which services we support with this instance. func (e *Instance) GetWellKnownServices() []wellknownservices.WellKnownService { return e.WellKnownServices } func (e *Instance) FindAddresses(context *fi.CloudupContext) ([]string, error) { cloud := context.T.Cloud.(openstack.OpenstackCloud) if e.Port == nil { return nil, nil } ports, err := cloud.GetPort(fi.ValueOf(e.Port.ID)) if err != nil { return nil, err } for _, port := range ports.FixedIPs { return []string{port.IPAddress}, nil } return nil, nil } // filterInstancePorts tries to get all ports of an instance tagged with the cluster name. // If no tagged ports are found it will return all ports of the instance, to not change the legacy behavior when there weren't tagged ports func filterInstancePorts(allPorts []ports.Port, clusterName string) []ports.Port { clusterNameTag := truncate.TruncateString(fmt.Sprintf("%s=%s", openstack.TagClusterName, clusterName), TRUNCATE_OPT) var taggedPorts []ports.Port for _, port := range allPorts { for _, tag := range port.Tags { if tag == clusterNameTag { taggedPorts = append(taggedPorts, port) break } } } if len(taggedPorts) == 0 { return allPorts } return taggedPorts } func (e *Instance) Find(c *fi.CloudupContext) (*Instance, error) { if e == nil || e.Name == nil { return nil, nil } cloud := c.T.Cloud.(openstack.OpenstackCloud) serverList, err := cloud.ListInstances(servers.ListOpts{ Name: fmt.Sprintf("^%s", fi.ValueOf(e.GroupName)), }) if err != nil { return nil, fmt.Errorf("error listing servers: %v", err) } var filteredList []servers.Server for _, server := range serverList { val, ok := server.Metadata["k8s"] if !ok || val != fi.ValueOf(e.ServerGroup.ClusterName) { continue } metadataName := "" val, ok = server.Metadata[openstack.TagKopsName] if ok { metadataName = val } // name or metadata tag should match to instance name // this is needed for backwards compatibility if server.Name == fi.ValueOf(e.Name) || metadataName == fi.ValueOf(e.Name) { filteredList = append(filteredList, server) } } if filteredList == nil { return nil, nil } if len(filteredList) > 1 { return nil, fmt.Errorf("Multiple servers found with name %s", fi.ValueOf(e.Name)) } server := filteredList[0] actual := &Instance{ ID: fi.PtrTo(server.ID), Name: e.Name, SSHKey: fi.PtrTo(server.KeyName), Lifecycle: e.Lifecycle, Metadata: server.Metadata, Role: fi.PtrTo(server.Metadata["KopsRole"]), AvailabilityZone: e.AvailabilityZone, GroupName: e.GroupName, ConfigDrive: e.ConfigDrive, Status: fi.PtrTo(server.Status), } ports, err := cloud.ListPorts(ports.ListOpts{ DeviceID: server.ID, }) if err != nil { return nil, fmt.Errorf("failed to fetch port for instance %v: %v", server.ID, err) } ports = filterInstancePorts(ports, fi.ValueOf(e.ServerGroup.ClusterName)) if len(ports) == 1 { port := ports[0] porttask, err := newPortTaskFromCloud(cloud, e.Lifecycle, &port, nil) if err != nil { return nil, fmt.Errorf("failed to fetch port for instance %v: %v", server.ID, err) } actual.Port = porttask } else if len(ports) > 1 { return nil, fmt.Errorf("found more than one port for instance %v", server.ID) } if e.FloatingIP != nil && e.Port != nil { fips, err := cloud.ListL3FloatingIPs(l3floatingip.ListOpts{ PortID: fi.ValueOf(e.Port.ID), }) if err != nil { return nil, fmt.Errorf("failed to fetch floating ips for instance %v: %v", server.ID, err) } if len(fips) == 1 { fip := fips[0] fipTask := &FloatingIP{ ID: fi.PtrTo(fip.ID), Name: fi.PtrTo(fip.Description), } actual.FloatingIP = fipTask } else if len(fips) > 1 { return nil, fmt.Errorf("found more than one floating ip for instance %v", server.ID) } } // Avoid flapping e.ID = actual.ID e.Status = fi.PtrTo(activeStatus) actual.WellKnownServices = e.WellKnownServices // Immutable fields actual.Flavor = e.Flavor actual.Image = e.Image actual.UserData = e.UserData actual.Region = e.Region actual.SSHKey = e.SSHKey actual.ServerGroup = e.ServerGroup return actual, nil } func (e *Instance) Run(c *fi.CloudupContext) error { return fi.CloudupDefaultDeltaRunMethod(e, c) } func (_ *Instance) CheckChanges(a, e, changes *Instance) error { if a == nil { if e.Name == nil { return fi.RequiredField("Name") } } else { if changes.ID != nil { return fi.CannotChangeField("ID") } if changes.Name != nil { return fi.CannotChangeField("Name") } } return nil } func (_ *Instance) ShouldCreate(a, e, changes *Instance) (bool, error) { if a == nil { return true, nil } if fi.ValueOf(a.Status) == errorStatus { return true, nil } if changes.Port != nil { return true, nil } if changes.FloatingIP != nil { return true, nil } return false, nil } // generateInstanceName generates name for the instance // the instance format is [GroupName]-[6 character hash] func generateInstanceName(e *Instance) (string, error) { secret, err := fi.CreateSecret() if err != nil { return "", err } hash, err := secret.AsString() if err != nil { return "", err } return strings.ToLower(fmt.Sprintf("%s-%s", fi.ValueOf(e.GroupName), hash[0:6])), nil } func (_ *Instance) RenderOpenstack(t *openstack.OpenstackAPITarget, a, e, changes *Instance) error { cloud := t.Cloud if a != nil && fi.ValueOf(a.Status) == errorStatus { klog.V(2).Infof("Delete previously failed server: %s\n", fi.ValueOf(a.ID)) cloud.DeleteInstanceWithID(fi.ValueOf(a.ID)) } if a == nil || fi.ValueOf(a.Status) == errorStatus { serverName, err := generateInstanceName(e) if err != nil { return err } klog.V(2).Infof("Creating Instance with name: %q", serverName) imageName := fi.ValueOf(e.Image) image, err := cloud.GetImage(imageName) if err != nil { return fmt.Errorf("failed to find image %v: %v", imageName, err) } flavorName := fi.ValueOf(e.Flavor) flavor, err := cloud.GetFlavor(flavorName) if err != nil { return fmt.Errorf("failed to find flavor %v: %v", flavorName, err) } opt := servers.CreateOpts{ Name: serverName, ImageRef: image.ID, FlavorRef: flavor.ID, Networks: []servers.Network{ { Port: fi.ValueOf(e.Port.ID), }, }, Metadata: e.Metadata, SecurityGroups: e.SecurityGroups, ConfigDrive: e.ConfigDrive, } if e.UserData != nil { bytes, err := fi.ResourceAsBytes(e.UserData) if err != nil { return err } opt.UserData = bytes } if e.AvailabilityZone != nil { opt.AvailabilityZone = fi.ValueOf(e.AvailabilityZone) } if opt, err = includeBootVolumeOptions(t, e, opt); err != nil { return err } keyext := keypairs.CreateOptsExt{ CreateOptsBuilder: opt, KeyName: openstackKeyPairName(fi.ValueOf(e.SSHKey)), } schedulerHints := servers.SchedulerHintOpts{Group: *e.ServerGroup.ID} v, err := t.Cloud.CreateInstance(keyext, schedulerHints, fi.ValueOf(e.Port.ID)) if err != nil { return fmt.Errorf("Error creating instance: %v", err) } e.ID = fi.PtrTo(v.ID) if e.FloatingIP != nil { err = associateFloatingIP(t, e) if err != nil { return err } } klog.V(2).Infof("Creating a new Openstack instance, id=%s", v.ID) return nil } if changes.Port != nil { _, err := attachinterfaces.Create(context.TODO(), cloud.ComputeClient(), fi.ValueOf(e.ID), attachinterfaces.CreateOpts{ PortID: fi.ValueOf(changes.Port.ID), }).Extract() if err != nil { return err } } if changes.FloatingIP != nil { err := associateFloatingIP(t, e) if err != nil { return err } } return nil } func associateFloatingIP(t *openstack.OpenstackAPITarget, e *Instance) error { client := t.Cloud.NetworkingClient() _, err := l3floatingip.Update(context.TODO(), client, fi.ValueOf(e.FloatingIP.ID), l3floatingip.UpdateOpts{ PortID: e.Port.ID, }).Extract() if err != nil { return fmt.Errorf("failed to associated floating IP to instance %s: %v", *e.Name, err) } return nil } func includeBootVolumeOptions(t *openstack.OpenstackAPITarget, e *Instance, opts servers.CreateOpts) (servers.CreateOpts, error) { if !bootFromVolume(e.Metadata) { return opts, nil } i, err := t.Cloud.GetImage(fi.ValueOf(e.Image)) if err != nil { return servers.CreateOpts{}, fmt.Errorf("Error getting image information: %v", err) } blockDevice := servers.BlockDevice{ BootIndex: 0, DeleteOnTermination: true, DestinationType: "volume", SourceType: "image", UUID: i.ID, VolumeSize: i.MinDiskGigabytes, } if s, ok := e.Metadata[openstack.BOOT_VOLUME_SIZE]; ok { i, err := strconv.ParseInt(s, 10, 64) if err != nil { return servers.CreateOpts{}, fmt.Errorf("Invalid value for %v: %v", openstack.BOOT_VOLUME_SIZE, err) } blockDevice.VolumeSize = int(i) } opts.BlockDevice = []servers.BlockDevice{blockDevice} return opts, nil } func bootFromVolume(m map[string]string) bool { v, ok := m[openstack.BOOT_FROM_VOLUME] if !ok { return false } switch v { case "true", "enabled": return true default: return false } }