| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you 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 cloudstack | |
| import ( | |
| "context" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "os" | |
| "strings" | |
| "time" | |
| "github.com/apache/cloudstack-go/v2/cloudstack" | |
| "github.com/blang/semver/v4" | |
| "gopkg.in/gcfg.v1" | |
| corev1 "k8s.io/api/core/v1" | |
| apierrors "k8s.io/apimachinery/pkg/api/errors" | |
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | |
| "k8s.io/apimachinery/pkg/types" | |
| "k8s.io/client-go/kubernetes" | |
| cloudprovider "k8s.io/cloud-provider" | |
| "k8s.io/klog/v2" | |
| ) | |
| // ProviderName is the name of this cloud provider. | |
| const ProviderName = "external-cloudstack" | |
| // CSConfig wraps the config for the CloudStack cloud provider. | |
| type CSConfig struct { | |
| Global struct { | |
| APIURL string `gcfg:"api-url"` | |
| APIKey string `gcfg:"api-key"` | |
| SecretKey string `gcfg:"secret-key"` | |
| SSLNoVerify bool `gcfg:"ssl-no-verify"` | |
| ProjectID string `gcfg:"project-id"` | |
| Zone string `gcfg:"zone"` | |
| Region string `gcfg:"region"` | |
| // Version overrides the CloudStack version that is otherwise | |
| // detected via the listCapabilities API. | |
| Version string `gcfg:"version"` | |
| } | |
| } | |
| // CSCloud is an implementation of Interface for CloudStack. | |
| type CSCloud struct { | |
| client *cloudstack.CloudStackClient | |
| projectID string // If non-"", all resources will be created within this project | |
| zone string | |
| region string | |
| version semver.Version | |
| clientBuilder cloudprovider.ControllerClientBuilder | |
| } | |
| func init() { | |
| cloudprovider.RegisterCloudProvider(ProviderName, func(config io.Reader) (cloudprovider.Interface, error) { | |
| cfg, err := readConfig(config) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return newCSCloud(cfg) | |
| }) | |
| } | |
| func readConfig(config io.Reader) (*CSConfig, error) { | |
| cfg := &CSConfig{} | |
| if config == nil { | |
| return cfg, nil | |
| } | |
| if err := gcfg.ReadInto(cfg, config); err != nil { | |
| return nil, fmt.Errorf("could not parse cloud provider config: %v", err) | |
| } | |
| return cfg, nil | |
| } | |
| // newCSCloud creates a new instance of CSCloud. | |
| func newCSCloud(cfg *CSConfig) (*CSCloud, error) { | |
| cs := &CSCloud{ | |
| projectID: cfg.Global.ProjectID, | |
| zone: cfg.Global.Zone, | |
| region: cfg.Global.Region, | |
| version: semver.Version{}, | |
| } | |
| if cfg.Global.APIURL != "" && cfg.Global.APIKey != "" && cfg.Global.SecretKey != "" { | |
| cs.client = cloudstack.NewAsyncClient(cfg.Global.APIURL, cfg.Global.APIKey, cfg.Global.SecretKey, !cfg.Global.SSLNoVerify) | |
| } | |
| if cs.client == nil { | |
| return nil, errors.New("no cloud provider config given") | |
| } | |
| if cfg.Global.Version != "" { | |
| version, err := parseCloudStackVersion(cfg.Global.Version) | |
| if err != nil { | |
| return nil, fmt.Errorf("could not parse the version given in the cloud provider config: %v", err) | |
| } | |
| klog.V(2).Infof("Using CloudStack version %v from the cloud provider config", version) | |
| cs.version = version | |
| return cs, nil | |
| } | |
| version, err := cs.getCloudStackVersion() | |
| if err != nil { | |
| return nil, err | |
| } | |
| cs.version = version | |
| return cs, nil | |
| } | |
| // getCloudStackVersion returns the version of the CloudStack management server, | |
| // as reported by the listCapabilities API. | |
| func (cs *CSCloud) getCloudStackVersion() (semver.Version, error) { | |
| capabilitiesResp, err := cs.client.Configuration.ListCapabilities(cs.client.Configuration.NewListCapabilitiesParams()) | |
| if err != nil { | |
| return semver.Version{}, err | |
| } | |
| if capabilitiesResp.Capabilities == nil || capabilitiesResp.Capabilities.Cloudstackversion == "" { | |
| return semver.Version{}, errors.New("no CloudStack version returned by the management server") | |
| } | |
| v, err := parseCloudStackVersion(capabilitiesResp.Capabilities.Cloudstackversion) | |
| if err != nil { | |
| klog.Error(err) | |
| return semver.Version{}, err | |
| } | |
| return v, nil | |
| } | |
| // parseCloudStackVersion parses a CloudStack version such as "4.17.1.0" or | |
| // "4.17.1.0-SNAPSHOT" into a semver version, discarding everything after the | |
| // patch level. | |
| func parseCloudStackVersion(version string) (semver.Version, error) { | |
| parts := strings.Split(version, ".") | |
| v, err := semver.ParseTolerant(strings.Join(parts[:min(len(parts), 3)], ".")) | |
| if err != nil { | |
| return semver.Version{}, fmt.Errorf("failed to parse CloudStack version %q: %v", version, err) | |
| } | |
| return v, nil | |
| } | |
| // Initialize passes a Kubernetes clientBuilder interface to the cloud provider | |
| func (cs *CSCloud) Initialize(clientBuilder cloudprovider.ControllerClientBuilder, stop <-chan struct{}) { | |
| cs.clientBuilder = clientBuilder | |
| } | |
| // LoadBalancer returns an implementation of LoadBalancer for CloudStack. | |
| func (cs *CSCloud) LoadBalancer() (cloudprovider.LoadBalancer, bool) { | |
| if cs.client == nil { | |
| return nil, false | |
| } | |
| return cs, true | |
| } | |
| // Instances returns an implementation of Instances for CloudStack. | |
| func (cs *CSCloud) Instances() (cloudprovider.Instances, bool) { | |
| if cs.client == nil { | |
| return nil, false | |
| } | |
| return cs, true | |
| } | |
| func (cs *CSCloud) InstancesV2() (cloudprovider.InstancesV2, bool) { | |
| if cs.client == nil { | |
| return nil, false | |
| } | |
| return cs, true | |
| } | |
| // Zones returns an implementation of Zones for CloudStack. | |
| func (cs *CSCloud) Zones() (cloudprovider.Zones, bool) { | |
| if cs.client == nil { | |
| return nil, false | |
| } | |
| return cs, true | |
| } | |
| // Clusters returns an implementation of Clusters for CloudStack. | |
| func (cs *CSCloud) Clusters() (cloudprovider.Clusters, bool) { | |
| if cs.client == nil { | |
| return nil, false | |
| } | |
| klog.Warning("This cloud provider doesn't support clusters") | |
| return nil, false | |
| } | |
| // Routes returns an implementation of Routes for CloudStack. | |
| func (cs *CSCloud) Routes() (cloudprovider.Routes, bool) { | |
| if cs.client == nil { | |
| return nil, false | |
| } | |
| klog.Warning("This cloud provider doesn't support routes") | |
| return nil, false | |
| } | |
| // ProviderName returns the cloud provider ID. | |
| func (cs *CSCloud) ProviderName() string { | |
| return ProviderName | |
| } | |
| // HasClusterID returns true if the cluster has a clusterID | |
| func (cs *CSCloud) HasClusterID() bool { | |
| return true | |
| } | |
| // GetZone returns the Zone containing the region that the program is running in. | |
| func (cs *CSCloud) GetZone(ctx context.Context) (cloudprovider.Zone, error) { | |
| zone := cloudprovider.Zone{} | |
| if cs.zone == "" { | |
| // In Kubernetes pods, os.Hostname() returns the pod name, not the node hostname. | |
| // We need to get the node name from the pod's spec.nodeName using the Kubernetes API. | |
| nodeName, err := cs.getNodeNameFromPod(ctx) | |
| if err != nil { | |
| return zone, fmt.Errorf("failed to get node name for retrieving the zone: %v", err) | |
| } | |
| instance, count, err := cs.client.VirtualMachine.GetVirtualMachineByName( | |
| nodeName, | |
| cloudstack.WithProject(cs.projectID), | |
| ) | |
| if err != nil { | |
| if count == 0 { | |
| return zone, fmt.Errorf("could not find CloudStack instance with name %s for retrieving the zone: %v", nodeName, err) | |
| } | |
| return zone, fmt.Errorf("error getting instance for retrieving the zone: %v", err) | |
| } | |
| cs.zone = instance.Zonename | |
| } | |
| klog.V(2).Infof("Current zone is %v", cs.zone) | |
| zone.FailureDomain = cs.zone | |
| zone.Region = cs.getRegionFromZone(cs.zone) | |
| return zone, nil | |
| } | |
| // GetZoneByProviderID returns the Zone, found by using the provider ID. | |
| func (cs *CSCloud) GetZoneByProviderID(ctx context.Context, providerID string) (cloudprovider.Zone, error) { | |
| zone := cloudprovider.Zone{} | |
| instance, count, err := cs.client.VirtualMachine.GetVirtualMachineByID( | |
| cs.getInstanceIDFromProviderID(providerID), | |
| cloudstack.WithProject(cs.projectID), | |
| ) | |
| if err != nil { | |
| if count == 0 { | |
| return zone, fmt.Errorf("could not find node by ID: %v", providerID) | |
| } | |
| return zone, fmt.Errorf("error retrieving zone: %v", err) | |
| } | |
| klog.V(2).Infof("Current zone is %v", cs.zone) | |
| zone.FailureDomain = instance.Zonename | |
| zone.Region = cs.getRegionFromZone(instance.Zonename) | |
| return zone, nil | |
| } | |
| // GetZoneByNodeName returns the Zone, found by using the node name. | |
| func (cs *CSCloud) GetZoneByNodeName(ctx context.Context, nodeName types.NodeName) (cloudprovider.Zone, error) { | |
| zone := cloudprovider.Zone{} | |
| instance, count, err := cs.client.VirtualMachine.GetVirtualMachineByName( | |
| string(nodeName), | |
| cloudstack.WithProject(cs.projectID), | |
| ) | |
| if err != nil { | |
| if count == 0 { | |
| return zone, fmt.Errorf("could not find node: %v", nodeName) | |
| } | |
| return zone, fmt.Errorf("error retrieving zone: %v", err) | |
| } | |
| klog.V(2).Infof("Current zone is %v", cs.zone) | |
| zone.FailureDomain = instance.Zonename | |
| zone.Region = cs.getRegionFromZone(instance.Zonename) | |
| return zone, nil | |
| } | |
| // getNodeNameFromPod gets the node name where this pod is running by querying the Kubernetes API. | |
| // It uses the pod's name and namespace (from environment variables or hostname) to look up the pod | |
| // and retrieve its spec.nodeName field. | |
| func (cs *CSCloud) getNodeNameFromPod(ctx context.Context) (string, error) { | |
| if cs.clientBuilder == nil { | |
| return "", fmt.Errorf("clientBuilder not initialized, cannot query Kubernetes API") | |
| } | |
| client, err := cs.clientBuilder.Client("cloud-controller-manager") | |
| if err != nil { | |
| return "", fmt.Errorf("failed to get Kubernetes client: %v", err) | |
| } | |
| // Get pod name and namespace | |
| // In Kubernetes, the pod name is available as HOSTNAME environment variable | |
| // or we can use os.Hostname() which returns the pod name | |
| podName := os.Getenv("HOSTNAME") | |
| if podName == "" { | |
| var err error | |
| podName, err = os.Hostname() | |
| if err != nil { | |
| return "", fmt.Errorf("failed to get pod name: %v", err) | |
| } | |
| } | |
| // Get namespace from environment variable or default to kube-system for CCM | |
| namespace := os.Getenv("POD_NAMESPACE") | |
| if namespace == "" { | |
| // Try reading from service account namespace file (available in pods) | |
| if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil { | |
| namespace = string(data) | |
| } else { | |
| // Default namespace for cloud controller manager | |
| namespace = "kube-system" | |
| } | |
| } | |
| // Get the pod object from Kubernetes API | |
| pod, err := client.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) | |
| if err != nil { | |
| return "", fmt.Errorf("failed to get pod %s/%s from Kubernetes API: %v", namespace, podName, err) | |
| } | |
| if pod.Spec.NodeName == "" { | |
| return "", fmt.Errorf("pod %s/%s does not have a nodeName assigned yet", namespace, podName) | |
| } | |
| klog.V(4).Infof("found node name %s for pod %s/%s", pod.Spec.NodeName, namespace, podName) | |
| return pod.Spec.NodeName, nil | |
| } | |
| // setServiceAnnotation updates a service annotation using the Kubernetes client. | |
| // It uses a patch operation with retry logic to handle concurrent updates safely. | |
| func (cs *CSCloud) setServiceAnnotation(ctx context.Context, service *corev1.Service, key, value string) error { | |
| if cs.clientBuilder == nil { | |
| klog.V(4).Infof("Client builder not available, skipping annotation update for service %s/%s", service.Namespace, service.Name) | |
| return nil | |
| } | |
| client, err := cs.clientBuilder.Client("cloud-controller-manager") | |
| if err != nil { | |
| return fmt.Errorf("failed to get Kubernetes client: %v", err) | |
| } | |
| // First, check if the annotation already has the correct value to avoid unnecessary updates | |
| svc, err := client.CoreV1().Services(service.Namespace).Get(ctx, service.Name, metav1.GetOptions{}) | |
| if err != nil { | |
| if apierrors.IsNotFound(err) { | |
| klog.V(4).Infof("Service %s/%s not found, skipping annotation update", service.Namespace, service.Name) | |
| return nil | |
| } | |
| return fmt.Errorf("failed to get service: %v", err) | |
| } | |
| // Check if annotation already has the correct value | |
| if svc.Annotations != nil { | |
| if existingValue, exists := svc.Annotations[key]; exists && existingValue == value { | |
| klog.V(4).Infof("Annotation %s already set to %s for service %s/%s", key, value, service.Namespace, service.Name) | |
| return nil | |
| } | |
| } | |
| // Use patch operation with retry logic to handle concurrent updates | |
| return cs.patchServiceAnnotation(ctx, client, service.Namespace, service.Name, key, value) | |
| } | |
| // patchServiceAnnotation patches a service annotation using a JSON merge patch with retry logic. | |
| // This method handles concurrent updates safely by retrying on conflicts. | |
| func (cs *CSCloud) patchServiceAnnotation(ctx context.Context, client kubernetes.Interface, namespace, name, key, value string) error { | |
| const maxRetries = 3 | |
| const retryDelay = 500 * time.Millisecond | |
| // Prepare the patch payload - merge patch that updates only the specific annotation | |
| // JSON merge patch will preserve other annotations while updating/adding this one | |
| patchData := map[string]interface{}{ | |
| "metadata": map[string]interface{}{ | |
| "annotations": map[string]string{ | |
| key: value, | |
| }, | |
| }, | |
| } | |
| patchBytes, err := json.Marshal(patchData) | |
| if err != nil { | |
| return fmt.Errorf("failed to marshal patch data: %v", err) | |
| } | |
| for attempt := 0; attempt < maxRetries; attempt++ { | |
| // Apply the patch using JSON merge patch type | |
| // This is atomic and avoids race conditions by merging with existing annotations | |
| _, err = client.CoreV1().Services(namespace).Patch( | |
| ctx, | |
| name, | |
| types.MergePatchType, | |
| patchBytes, | |
| metav1.PatchOptions{}, | |
| ) | |
| if err == nil { | |
| klog.V(4).Infof("Successfully set annotation %s=%s on service %s/%s", key, value, namespace, name) | |
| return nil | |
| } | |
| // Handle conflict errors with retry logic | |
| if apierrors.IsConflict(err) { | |
| if attempt < maxRetries-1 { | |
| klog.V(4).Infof("Conflict updating service %s/%s annotation, retrying (attempt %d/%d): %v", namespace, name, attempt+1, maxRetries, err) | |
| time.Sleep(retryDelay) | |
| continue | |
| } | |
| return fmt.Errorf("failed to update service annotation after %d retries due to conflicts: %v", maxRetries, err) | |
| } | |
| // Handle not found errors | |
| if apierrors.IsNotFound(err) { | |
| klog.V(4).Infof("Service %s/%s not found during patch, skipping annotation update", namespace, name) | |
| return nil | |
| } | |
| // For other errors, return immediately | |
| return fmt.Errorf("failed to patch service annotation: %v", err) | |
| } | |
| return fmt.Errorf("failed to update service annotation after %d attempts", maxRetries) | |
| } | |
| func (cs *CSCloud) getRegionFromZone(zone string) string { | |
| if cs.region != "" { | |
| return cs.region | |
| } | |
| return zone | |
| } |