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 }