github.com/ironcore-dev/gardener-extension-provider-ironcore@v0.3.2-0.20240314231816-8336447fb9a0/pkg/controller/bastion/actuator_reconcile.go (about)

     1  // SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and IronCore contributors
     2  // SPDX-License-Identifier: Apache-2.0
     3  
     4  package bastion
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"net"
    10  	"net/netip"
    11  	"time"
    12  
    13  	"github.com/gardener/gardener/extensions/pkg/controller"
    14  	extensionsv1alpha1 "github.com/gardener/gardener/pkg/apis/extensions/v1alpha1"
    15  	reconcilerutils "github.com/gardener/gardener/pkg/controllerutils/reconciler"
    16  	"github.com/go-logr/logr"
    17  	commonv1alpha1 "github.com/ironcore-dev/ironcore/api/common/v1alpha1"
    18  	computev1alpha1 "github.com/ironcore-dev/ironcore/api/compute/v1alpha1"
    19  	corev1alpha1 "github.com/ironcore-dev/ironcore/api/core/v1alpha1"
    20  	ipamv1alpha1 "github.com/ironcore-dev/ironcore/api/ipam/v1alpha1"
    21  	networkingv1alpha1 "github.com/ironcore-dev/ironcore/api/networking/v1alpha1"
    22  	storagev1alpha1 "github.com/ironcore-dev/ironcore/api/storage/v1alpha1"
    23  	corev1 "k8s.io/api/core/v1"
    24  	"k8s.io/apimachinery/pkg/api/resource"
    25  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    26  	"sigs.k8s.io/controller-runtime/pkg/client"
    27  	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    28  
    29  	controllerconfig "github.com/ironcore-dev/gardener-extension-provider-ironcore/pkg/apis/config"
    30  	api "github.com/ironcore-dev/gardener-extension-provider-ironcore/pkg/apis/ironcore"
    31  	"github.com/ironcore-dev/gardener-extension-provider-ironcore/pkg/controller/bastion/ignition"
    32  	"github.com/ironcore-dev/gardener-extension-provider-ironcore/pkg/ironcore"
    33  )
    34  
    35  const (
    36  	// sshPort is the default SSH Port used for bastion ingress firewall rule
    37  	sshPort = 22
    38  	// name is the network interface label key
    39  	name = "bastion-host"
    40  )
    41  
    42  // bastionEndpoints collects the endpoints the bastion host provides; the
    43  // private endpoint is important for opening a port on the worker node
    44  // ingress network policy rule to allow SSH from that node, the public endpoint is where
    45  // the end user connects to establish the SSH connection.
    46  type bastionEndpoints struct {
    47  	private *corev1.LoadBalancerIngress
    48  	public  *corev1.LoadBalancerIngress
    49  }
    50  
    51  // Reconcile implements bastion.Actuator.
    52  func (a *actuator) Reconcile(ctx context.Context, log logr.Logger, bastion *extensionsv1alpha1.Bastion, cluster *controller.Cluster) error {
    53  	return a.reconcile(ctx, log, bastion, cluster)
    54  }
    55  
    56  func (a *actuator) reconcile(ctx context.Context, log logr.Logger, bastion *extensionsv1alpha1.Bastion, cluster *controller.Cluster) error {
    57  	log.V(2).Info("Reconciling bastion host")
    58  
    59  	if err := validateConfiguration(a.bastionConfig); err != nil {
    60  		return fmt.Errorf("error validating configuration: %w", err)
    61  	}
    62  
    63  	opt, err := DetermineOptions(bastion, cluster)
    64  	if err != nil {
    65  		return fmt.Errorf("failed to determine options: %w", err)
    66  	}
    67  
    68  	infraStatus, err := getInfrastructureStatus(ctx, a.client, cluster)
    69  	if err != nil {
    70  		return fmt.Errorf("failed to get infrastructure status: %w", err)
    71  	}
    72  
    73  	ironcoreClient, namespace, err := ironcore.GetIroncoreClientAndNamespaceFromCloudProviderSecret(ctx, a.client, cluster.ObjectMeta.Name)
    74  	if err != nil {
    75  		return fmt.Errorf("failed to get ironcore client and namespace from cloudprovider secret: %w", err)
    76  	}
    77  
    78  	machine, err := a.applyMachineAndIgnitionSecret(ctx, namespace, ironcoreClient, infraStatus, opt)
    79  	if err != nil {
    80  		return fmt.Errorf("failed to create machine: %w", err)
    81  	}
    82  
    83  	if err = ensureNetworkPolicy(ctx, namespace, bastion, ironcoreClient, infraStatus, machine); err != nil {
    84  		return fmt.Errorf("failed to create network policy: %w", err)
    85  	}
    86  
    87  	endpoints, err := getMachineEndpoints(machine)
    88  	if err != nil {
    89  		return fmt.Errorf("failed to get machine endpoints: %w", err)
    90  	}
    91  
    92  	if !endpoints.Ready() {
    93  		return &reconcilerutils.RequeueAfterError{
    94  			// requeue rather soon, so that the user (most likely gardenctl eventually)
    95  			// doesn't have to wait too long for the public endpoint to become available
    96  			RequeueAfter: 5 * time.Second,
    97  			Cause:        fmt.Errorf("bastion instance has no public/private endpoints yet"),
    98  		}
    99  	}
   100  
   101  	// once a public endpoint is available, publish the endpoint on the
   102  	// Bastion resource to notify upstream about the ready instance
   103  	log.V(2).Info("Reconciled bastion host")
   104  	patch := client.MergeFrom(bastion.DeepCopy())
   105  	bastion.Status.Ingress = endpoints.public
   106  	return a.client.Status().Patch(ctx, bastion, patch)
   107  }
   108  
   109  // getMachineEndpoints function returns the bastion endpoints of a running
   110  // machine. It first validates that the machine is in running state, then
   111  // extracts the private and public IP of the machine's network interface, and
   112  // finally converts the IPs to their respective ingress addresses.
   113  func getMachineEndpoints(machine *computev1alpha1.Machine) (*bastionEndpoints, error) {
   114  	if machine == nil {
   115  		return nil, fmt.Errorf("machine can not be nil")
   116  	}
   117  
   118  	if machine.Status.State != computev1alpha1.MachineStateRunning {
   119  		return nil, fmt.Errorf("machine not running, status: %s", machine.Status.State)
   120  	}
   121  
   122  	endpoints := &bastionEndpoints{}
   123  
   124  	if len(machine.Status.NetworkInterfaces) == 0 {
   125  		return nil, fmt.Errorf("no network interface found for machine: %s", machine.Name)
   126  	}
   127  
   128  	privateIP, virtualIP, err := getPrivateAndVirtualIPsFromNetworkInterfaces(machine.Status.NetworkInterfaces)
   129  	if err != nil {
   130  		return nil, fmt.Errorf("failed to get ips from network interfaces: %s", machine.Name)
   131  
   132  	}
   133  
   134  	if ingress := addressToIngress(&machine.Name, &privateIP); ingress != nil {
   135  		endpoints.private = ingress
   136  	}
   137  
   138  	if ingress := addressToIngress(&machine.Name, &virtualIP); ingress != nil {
   139  		endpoints.public = ingress
   140  	}
   141  
   142  	return endpoints, nil
   143  }
   144  
   145  // applyMachineAndIgnitionSecret applies the configuration to create or update
   146  // the bastion host machine and the ignition secret used for provisioning the
   147  // bastion host machine. It first sets the owner reference for the ignition
   148  // secret to the bastion host machine, to ensure that the secret is garbage
   149  // collected when the bastion host is deleted.
   150  func (a *actuator) applyMachineAndIgnitionSecret(ctx context.Context, namespace string, ironcoreClient client.Client, infraStatus *api.InfrastructureStatus, opt *Options) (*computev1alpha1.Machine, error) {
   151  	ignitionSecret, err := generateIgnitionSecret(namespace, opt)
   152  	if err != nil {
   153  		return nil, fmt.Errorf("failed to create ignition secret: %w", err)
   154  	}
   155  
   156  	bastionHost := generateMachine(namespace, a.bastionConfig, infraStatus, opt.BastionInstanceName, ignitionSecret.Name)
   157  
   158  	if _, err = controllerutil.CreateOrPatch(ctx, ironcoreClient, bastionHost, nil); err != nil {
   159  		return nil, fmt.Errorf("failed to create or patch bastion host machine %s: %w", client.ObjectKeyFromObject(bastionHost), err)
   160  	}
   161  
   162  	if err := controllerutil.SetOwnerReference(bastionHost, ignitionSecret, ironcoreClient.Scheme()); err != nil {
   163  		return nil, fmt.Errorf("failed to set owner reference for ignition secret %s: %w", client.ObjectKeyFromObject(ignitionSecret), err)
   164  	}
   165  
   166  	if _, err = controllerutil.CreateOrPatch(ctx, ironcoreClient, ignitionSecret, nil); err != nil {
   167  		return nil, fmt.Errorf("failed to create or patch ignition secret %s for bastion host %s: %w", client.ObjectKeyFromObject(ignitionSecret), client.ObjectKeyFromObject(bastionHost), err)
   168  	}
   169  
   170  	return bastionHost, nil
   171  }
   172  
   173  // generateIgnitionSecret constructs a Kubernetes secret object containing an ignition file for the Bastion host
   174  func generateIgnitionSecret(namespace string, opt *Options) (*corev1.Secret, error) {
   175  	// Construct ignition file config
   176  	config := &ignition.Config{
   177  		Hostname:   opt.BastionInstanceName,
   178  		UserData:   string(opt.UserData),
   179  		DnsServers: []netip.Addr{netip.MustParseAddr("8.8.8.8")},
   180  	}
   181  
   182  	ignitionContent, err := ignition.File(config)
   183  	if err != nil {
   184  		return nil, fmt.Errorf("failed to create ignition file for machine %s: %w", opt.BastionInstanceName, err)
   185  	}
   186  
   187  	ignitionData := map[string][]byte{}
   188  	ignitionData[computev1alpha1.DefaultIgnitionKey] = []byte(ignitionContent)
   189  	ignitionSecret := &corev1.Secret{
   190  		ObjectMeta: metav1.ObjectMeta{
   191  			Name:      getIgnitionNameForMachine(opt.BastionInstanceName),
   192  			Namespace: namespace,
   193  		},
   194  		Data: ignitionData,
   195  	}
   196  
   197  	return ignitionSecret, nil
   198  }
   199  
   200  // generateMachine constructs a Machine object for the Bastion instance
   201  func generateMachine(namespace string, bastionConfig *controllerconfig.BastionConfig, infraStatus *api.InfrastructureStatus, BastionInstanceName string, ignitionSecretName string) *computev1alpha1.Machine {
   202  	bastionHost := &computev1alpha1.Machine{
   203  		ObjectMeta: metav1.ObjectMeta{
   204  			Name:      BastionInstanceName,
   205  			Namespace: namespace,
   206  		},
   207  		Spec: computev1alpha1.MachineSpec{
   208  			MachineClassRef: corev1.LocalObjectReference{
   209  				Name: bastionConfig.MachineClassName,
   210  			},
   211  			Power: computev1alpha1.PowerOn,
   212  			Volumes: []computev1alpha1.Volume{
   213  				{
   214  					Name: "root",
   215  					VolumeSource: computev1alpha1.VolumeSource{
   216  						Ephemeral: &computev1alpha1.EphemeralVolumeSource{
   217  							VolumeTemplate: &storagev1alpha1.VolumeTemplateSpec{
   218  								Spec: storagev1alpha1.VolumeSpec{
   219  									VolumeClassRef: &corev1.LocalObjectReference{
   220  										Name: bastionConfig.VolumeClassName,
   221  									},
   222  									Resources: corev1alpha1.ResourceList{
   223  										corev1alpha1.ResourceStorage: resource.MustParse("10Gi"),
   224  									},
   225  									Image: bastionConfig.Image,
   226  								},
   227  							},
   228  						},
   229  					},
   230  				},
   231  			},
   232  			NetworkInterfaces: []computev1alpha1.NetworkInterface{
   233  				{
   234  					Name: "primary",
   235  					NetworkInterfaceSource: computev1alpha1.NetworkInterfaceSource{
   236  						Ephemeral: &computev1alpha1.EphemeralNetworkInterfaceSource{
   237  							NetworkInterfaceTemplate: &networkingv1alpha1.NetworkInterfaceTemplateSpec{
   238  								ObjectMeta: metav1.ObjectMeta{
   239  									Labels: map[string]string{
   240  										name: BastionInstanceName,
   241  									},
   242  								},
   243  								Spec: networkingv1alpha1.NetworkInterfaceSpec{
   244  									NetworkRef: corev1.LocalObjectReference{
   245  										Name: infraStatus.NetworkRef.Name,
   246  									},
   247  									IPFamilies: []corev1.IPFamily{corev1.IPv4Protocol},
   248  									IPs: []networkingv1alpha1.IPSource{
   249  										{
   250  											Ephemeral: &networkingv1alpha1.EphemeralPrefixSource{
   251  												PrefixTemplate: &ipamv1alpha1.PrefixTemplateSpec{
   252  													Spec: ipamv1alpha1.PrefixSpec{
   253  														// request single IP
   254  														PrefixLength: 32,
   255  														ParentRef: &corev1.LocalObjectReference{
   256  															Name: infraStatus.PrefixRef.Name,
   257  														},
   258  													},
   259  												},
   260  											},
   261  										},
   262  									},
   263  									VirtualIP: &networkingv1alpha1.VirtualIPSource{
   264  										Ephemeral: &networkingv1alpha1.EphemeralVirtualIPSource{
   265  											VirtualIPTemplate: &networkingv1alpha1.VirtualIPTemplateSpec{
   266  												Spec: networkingv1alpha1.VirtualIPSpec{
   267  													Type:     networkingv1alpha1.VirtualIPTypePublic,
   268  													IPFamily: corev1.IPv4Protocol,
   269  												},
   270  											},
   271  										},
   272  									},
   273  								},
   274  							},
   275  						},
   276  					},
   277  				},
   278  			},
   279  			IgnitionRef: &commonv1alpha1.SecretKeySelector{
   280  				Name: ignitionSecretName,
   281  			},
   282  		},
   283  	}
   284  	return bastionHost
   285  }
   286  
   287  // addressToIngress converts the IP address into a
   288  // corev1.LoadBalancerIngress resource. If both arguments are nil, then
   289  // nil is returned.
   290  func addressToIngress(dnsName *string, ipAddress *string) *corev1.LoadBalancerIngress {
   291  	var ingress *corev1.LoadBalancerIngress
   292  
   293  	if ipAddress != nil || dnsName != nil {
   294  		ingress = &corev1.LoadBalancerIngress{}
   295  		if dnsName != nil {
   296  			ingress.Hostname = *dnsName
   297  		}
   298  
   299  		if ipAddress != nil {
   300  			ingress.IP = *ipAddress
   301  		}
   302  	}
   303  
   304  	return ingress
   305  }
   306  
   307  // Ready returns true if both public and private interfaces each have either
   308  // an IP or a hostname or both.
   309  func (be *bastionEndpoints) Ready() bool {
   310  	return be != nil && IngressReady(be.private) && IngressReady(be.public)
   311  }
   312  
   313  // IngressReady returns true if either an IP or a hostname or both are set.
   314  func IngressReady(ingress *corev1.LoadBalancerIngress) bool {
   315  	return ingress != nil && (ingress.Hostname != "" || ingress.IP != "")
   316  }
   317  
   318  func ensureNetworkPolicy(ctx context.Context, namespace string, bastion *extensionsv1alpha1.Bastion, ironcoreClient client.Client, infraStatus *api.InfrastructureStatus, bastionHost *computev1alpha1.Machine) error {
   319  	cidrs, err := getBastionIngressCIDR(bastion)
   320  	if err != nil {
   321  		return fmt.Errorf("failed to get CIDR from bastion ingress: %w", err)
   322  	}
   323  
   324  	networkPolicy := &networkingv1alpha1.NetworkPolicy{
   325  		ObjectMeta: metav1.ObjectMeta{
   326  			Name:      bastionHost.Name,
   327  			Namespace: namespace,
   328  		},
   329  		Spec: networkingv1alpha1.NetworkPolicySpec{
   330  			NetworkRef: corev1.LocalObjectReference{
   331  				Name: infraStatus.NetworkRef.Name,
   332  			},
   333  			NetworkInterfaceSelector: metav1.LabelSelector{
   334  				MatchLabels: map[string]string{
   335  					name: bastionHost.Name,
   336  				},
   337  			},
   338  			Ingress: []networkingv1alpha1.NetworkPolicyIngressRule{},
   339  			PolicyTypes: []networkingv1alpha1.PolicyType{
   340  				networkingv1alpha1.PolicyTypeIngress,
   341  			},
   342  		},
   343  	}
   344  
   345  	for _, cidr := range cidrs {
   346  		ingressRule := networkingv1alpha1.NetworkPolicyIngressRule{
   347  			Ports: []networkingv1alpha1.NetworkPolicyPort{
   348  				{
   349  					Port: sshPort,
   350  				},
   351  			},
   352  			From: []networkingv1alpha1.NetworkPolicyPeer{
   353  				{
   354  					IPBlock: &networkingv1alpha1.IPBlock{
   355  						CIDR: commonv1alpha1.MustParseIPPrefix(cidr),
   356  					},
   357  				},
   358  			},
   359  		}
   360  		networkPolicy.Spec.Ingress = append(networkPolicy.Spec.Ingress, ingressRule)
   361  	}
   362  
   363  	if err := controllerutil.SetOwnerReference(bastionHost, networkPolicy, ironcoreClient.Scheme()); err != nil {
   364  		return fmt.Errorf("failed to set owner reference for network policy %s: %w", client.ObjectKeyFromObject(networkPolicy), err)
   365  	}
   366  
   367  	if _, err = controllerutil.CreateOrPatch(ctx, ironcoreClient, networkPolicy, nil); err != nil {
   368  		return fmt.Errorf("failed to create or patch network policy %s: %w", client.ObjectKeyFromObject(networkPolicy), err)
   369  	}
   370  
   371  	return err
   372  }
   373  
   374  func getBastionIngressCIDR(bastion *extensionsv1alpha1.Bastion) ([]string, error) {
   375  	var cidrs []string
   376  	for _, ingress := range bastion.Spec.Ingress {
   377  		cidr := ingress.IPBlock.CIDR
   378  		_, ipNet, err := net.ParseCIDR(cidr)
   379  		if err != nil {
   380  			return nil, fmt.Errorf("invalid ingress CIDR %q: %w", cidr, err)
   381  		}
   382  		normalisedCIDR := ipNet.String()
   383  		cidrs = append(cidrs, normalisedCIDR)
   384  	}
   385  	return cidrs, nil
   386  }