diff --git a/api/v1alpha3/azurecluster_conversion.go b/api/v1alpha3/azurecluster_conversion.go index 59e514a2287..92416697515 100644 --- a/api/v1alpha3/azurecluster_conversion.go +++ b/api/v1alpha3/azurecluster_conversion.go @@ -54,6 +54,7 @@ func (src *AzureCluster) ConvertTo(dstRaw conversion.Hub) error { // nolint dst.Spec.NetworkSpec.APIServerLB.FrontendIPsCount = restored.Spec.NetworkSpec.APIServerLB.FrontendIPsCount dst.Spec.NetworkSpec.NodeOutboundLB = restored.Spec.NetworkSpec.NodeOutboundLB dst.Spec.CloudProviderConfigOverrides = restored.Spec.CloudProviderConfigOverrides + dst.Spec.BastionSpec = restored.Spec.BastionSpec // Here we manually restore outbound security rules. Since v1alpha3 only supports ingress ("Inbound") rules, all v1alpha4 outbound rules are dropped when an AzureCluster // is converted to v1alpha3. We loop through all security group rules. For all previously existing outbound rules we restore the full rule. @@ -95,11 +96,6 @@ func (dst *AzureCluster) ConvertFrom(srcRaw conversion.Hub) error { // nolint return err } - // Preserve Hub data on down-conversion. - if err := utilconversion.MarshalData(src, dst); err != nil { - return err - } - return nil } diff --git a/api/v1alpha3/zz_generated.conversion.go b/api/v1alpha3/zz_generated.conversion.go index 40e28a267e6..e342656f47d 100644 --- a/api/v1alpha3/zz_generated.conversion.go +++ b/api/v1alpha3/zz_generated.conversion.go @@ -691,6 +691,7 @@ func autoConvert_v1alpha4_AzureClusterSpec_To_v1alpha3_AzureClusterSpec(in *v1al out.AdditionalTags = *(*Tags)(unsafe.Pointer(&in.AdditionalTags)) out.IdentityRef = (*v1.ObjectReference)(unsafe.Pointer(in.IdentityRef)) // WARNING: in.AzureEnvironment requires manual conversion: does not exist in peer-type + // WARNING: in.BastionSpec requires manual conversion: does not exist in peer-type // WARNING: in.CloudProviderConfigOverrides requires manual conversion: does not exist in peer-type return nil } diff --git a/api/v1alpha4/azurecluster_default.go b/api/v1alpha4/azurecluster_default.go index 11d5f9aef8f..25da7cef193 100644 --- a/api/v1alpha4/azurecluster_default.go +++ b/api/v1alpha4/azurecluster_default.go @@ -29,6 +29,10 @@ const ( DefaultControlPlaneSubnetCIDR = "10.0.0.0/16" // DefaultNodeSubnetCIDR is the default Node Subnet CIDR DefaultNodeSubnetCIDR = "10.1.0.0/16" + // DefaultAzureBastionSubnetCIDR is the default Subnet CIDR for AzureBastion + DefaultAzureBastionSubnetCIDR = "10.255.255.224/27" + // DefaultAzureBastionSubnetName is the default Subnet Name for AzureBastion + DefaultAzureBastionSubnetName = "AzureBastionSubnet" // DefaultInternalLBIPAddress is the default internal load balancer ip address DefaultInternalLBIPAddress = "10.0.0.100" // DefaultAzureCloud is the public cloud that will be used by most users @@ -43,6 +47,7 @@ func (c *AzureCluster) setDefaults() { func (c *AzureCluster) setNetworkSpecDefaults() { c.setVnetDefaults() + c.setBastionDefaults() c.setSubnetDefaults() c.setAPIServerLBDefaults() c.setNodeOutboundLBDefaults() @@ -204,6 +209,29 @@ func (c *AzureCluster) setNodeOutboundLBDefaults() { } } +func (c *AzureCluster) setBastionDefaults() { + if c.Spec.BastionSpec.AzureBastion != nil { + if c.Spec.BastionSpec.AzureBastion.Name == "" { + c.Spec.BastionSpec.AzureBastion.Name = generateAzureBastionName(c.ObjectMeta.Name) + } + // Ensure defaults for the Subnet settings. + { + if c.Spec.BastionSpec.AzureBastion.Subnet.Name == "" { + c.Spec.BastionSpec.AzureBastion.Subnet.Name = DefaultAzureBastionSubnetName + } + if len(c.Spec.BastionSpec.AzureBastion.Subnet.CIDRBlocks) == 0 { + c.Spec.BastionSpec.AzureBastion.Subnet.CIDRBlocks = []string{DefaultAzureBastionSubnetCIDR} + } + } + // Ensure defaults for the PublicIP settings. + { + if c.Spec.BastionSpec.AzureBastion.PublicIP.Name == "" { + c.Spec.BastionSpec.AzureBastion.PublicIP.Name = generateAzureBastionPublicIPName(c.ObjectMeta.Name) + } + } + } +} + // generateVnetName generates a virtual network name, based on the cluster name. func generateVnetName(clusterName string) string { return fmt.Sprintf("%s-%s", clusterName, "vnet") @@ -219,6 +247,16 @@ func generateNodeSubnetName(clusterName string) string { return fmt.Sprintf("%s-%s", clusterName, "node-subnet") } +// generateAzureBastionName generates an azure bastion name. +func generateAzureBastionName(clusterName string) string { + return fmt.Sprintf("%s-azure-bastion", clusterName) +} + +// generateAzureBastionPublicIPName generates an azure bastion public ip name. +func generateAzureBastionPublicIPName(clusterName string) string { + return fmt.Sprintf("%s-azure-bastion-pip", clusterName) +} + // generateControlPlaneSecurityGroupName generates a control plane security group name, based on the cluster name. func generateControlPlaneSecurityGroupName(clusterName string) string { return fmt.Sprintf("%s-%s", clusterName, "controlplane-nsg") diff --git a/api/v1alpha4/azurecluster_default_test.go b/api/v1alpha4/azurecluster_default_test.go index 830997383c2..8c40aab8e7a 100644 --- a/api/v1alpha4/azurecluster_default_test.go +++ b/api/v1alpha4/azurecluster_default_test.go @@ -910,3 +910,206 @@ func TestNodeOutboundLBDefaults(t *testing.T) { }) } } + +func TestBastionDefault(t *testing.T) { + cases := map[string]struct { + cluster *AzureCluster + output *AzureCluster + }{ + "no bastion set": { + cluster: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{}, + }, + output: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{}, + }, + }, + "azure bastion enabled with no settings": { + cluster: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{}, + }, + }, + }, + output: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + Name: "foo-azure-bastion", + Subnet: SubnetSpec{ + Name: "AzureBastionSubnet", + CIDRBlocks: []string{DefaultAzureBastionSubnetCIDR}, + }, + PublicIP: PublicIPSpec{ + Name: "foo-azure-bastion-pip", + }, + }, + }, + }, + }, + }, + "azure bastion enabled with name set": { + cluster: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + Name: "my-fancy-name", + }, + }, + }, + }, + output: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + Name: "my-fancy-name", + Subnet: SubnetSpec{ + Name: "AzureBastionSubnet", + CIDRBlocks: []string{DefaultAzureBastionSubnetCIDR}, + }, + PublicIP: PublicIPSpec{ + Name: "foo-azure-bastion-pip", + }, + }, + }, + }, + }, + }, + "azure bastion enabled with subnet partially set": { + cluster: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + Subnet: SubnetSpec{}, + }, + }, + }, + }, + output: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + Name: "foo-azure-bastion", + Subnet: SubnetSpec{ + Name: "AzureBastionSubnet", + CIDRBlocks: []string{DefaultAzureBastionSubnetCIDR}, + }, + PublicIP: PublicIPSpec{ + Name: "foo-azure-bastion-pip", + }, + }, + }, + }, + }, + }, + "azure bastion enabled with subnet fully set": { + cluster: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + Subnet: SubnetSpec{ + Name: "my-superfancy-name", + CIDRBlocks: []string{"10.10.0.0/16"}, + }, + }, + }, + }, + }, + output: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + Name: "foo-azure-bastion", + Subnet: SubnetSpec{ + Name: "my-superfancy-name", + CIDRBlocks: []string{"10.10.0.0/16"}, + }, + PublicIP: PublicIPSpec{ + Name: "foo-azure-bastion-pip", + }, + }, + }, + }, + }, + }, + "azure bastion enabled with public IP name set": { + cluster: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + PublicIP: PublicIPSpec{ + Name: "my-ultrafancy-pip-name", + }, + }, + }, + }, + }, + output: &AzureCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: AzureClusterSpec{ + BastionSpec: BastionSpec{ + AzureBastion: &AzureBastion{ + Name: "foo-azure-bastion", + Subnet: SubnetSpec{ + Name: "AzureBastionSubnet", + CIDRBlocks: []string{DefaultAzureBastionSubnetCIDR}, + }, + PublicIP: PublicIPSpec{ + Name: "my-ultrafancy-pip-name", + }, + }, + }, + }, + }, + }, + } + + for name := range cases { + c := cases[name] + t.Run(name, func(t *testing.T) { + t.Parallel() + c.cluster.setBastionDefaults() + if !reflect.DeepEqual(c.cluster, c.output) { + expected, _ := json.MarshalIndent(c.output, "", "\t") + actual, _ := json.MarshalIndent(c.cluster, "", "\t") + t.Errorf("Expected %s, got %s", string(expected), string(actual)) + } + }) + } +} diff --git a/api/v1alpha4/azurecluster_types.go b/api/v1alpha4/azurecluster_types.go index fb057682e53..72a4557641c 100644 --- a/api/v1alpha4/azurecluster_types.go +++ b/api/v1alpha4/azurecluster_types.go @@ -66,6 +66,10 @@ type AzureClusterSpec struct { // +optional AzureEnvironment string `json:"azureEnvironment,omitempty"` + // BastionSpec encapsulates all things related to the Bastions in the cluster. + // +optional + BastionSpec BastionSpec `json:"bastionSpec,omitempty"` + // CloudProviderConfigOverrides is an optional set of configuration values that can be overridden in azure cloud provider config. // This is only a subset of options that are available in azure cloud provider config. // Some values for the cloud provider config are inferred from other parts of cluster api provider azure spec, and may not be available for overrides. diff --git a/api/v1alpha4/azurecluster_webhook.go b/api/v1alpha4/azurecluster_webhook.go index 69b71892932..24b6a9bc171 100644 --- a/api/v1alpha4/azurecluster_webhook.go +++ b/api/v1alpha4/azurecluster_webhook.go @@ -98,6 +98,14 @@ func (c *AzureCluster) ValidateUpdate(oldRaw runtime.Object) error { ) } + // Allow enabling azure bastion but avoid disabling it. + if old.Spec.BastionSpec.AzureBastion != nil && !reflect.DeepEqual(old.Spec.BastionSpec.AzureBastion, c.Spec.BastionSpec.AzureBastion) { + allErrs = append(allErrs, + field.Invalid(field.NewPath("spec", "BastionSpec", "AzureBastion"), + c.Spec.BastionSpec.AzureBastion, "azure bastion cannot be removed from a cluster"), + ) + } + if len(allErrs) == 0 { return c.validateCluster(old) } diff --git a/api/v1alpha4/types.go b/api/v1alpha4/types.go index 36878a6153c..adb47f08f38 100644 --- a/api/v1alpha4/types.go +++ b/api/v1alpha4/types.go @@ -571,3 +571,19 @@ const ( // AvailabilitySetRateLimit ... AvailabilitySetRateLimit = "availabilitySetRateLimit" ) + +// BastionSpec specifies how the Bastion feature should be set up for the cluster. +type BastionSpec struct { + // +optional + AzureBastion *AzureBastion `json:"azureBastion,omitempty"` +} + +// AzureBastion specifies how the Azure Bastion cloud component should be configured. +type AzureBastion struct { + // +optional + Name string `json:"name,omitempty"` + // +optional + Subnet SubnetSpec `json:"subnet,omitempty"` + // +optional + PublicIP PublicIPSpec `json:"publicIP,omitempty"` +} diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index e7ae9ba283c..11d6182a9fa 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -68,6 +68,23 @@ func (in *AllowedNamespaces) DeepCopy() *AllowedNamespaces { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AzureBastion) DeepCopyInto(out *AzureBastion) { + *out = *in + in.Subnet.DeepCopyInto(&out.Subnet) + out.PublicIP = in.PublicIP +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureBastion. +func (in *AzureBastion) DeepCopy() *AzureBastion { + if in == nil { + return nil + } + out := new(AzureBastion) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AzureCluster) DeepCopyInto(out *AzureCluster) { *out = *in @@ -246,6 +263,7 @@ func (in *AzureClusterSpec) DeepCopyInto(out *AzureClusterSpec) { *out = new(v1.ObjectReference) **out = **in } + in.BastionSpec.DeepCopyInto(&out.BastionSpec) if in.CloudProviderConfigOverrides != nil { in, out := &in.CloudProviderConfigOverrides, &out.CloudProviderConfigOverrides *out = new(CloudProviderConfigOverrides) @@ -578,6 +596,26 @@ func (in *AzureSharedGalleryImage) DeepCopy() *AzureSharedGalleryImage { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BastionSpec) DeepCopyInto(out *BastionSpec) { + *out = *in + if in.AzureBastion != nil { + in, out := &in.AzureBastion, &out.AzureBastion + *out = new(AzureBastion) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BastionSpec. +func (in *BastionSpec) DeepCopy() *BastionSpec { + if in == nil { + return nil + } + out := new(BastionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BuildParams) DeepCopyInto(out *BuildParams) { *out = *in diff --git a/azure/scope/cluster.go b/azure/scope/cluster.go index 2f3289c3786..4a5e18e298a 100644 --- a/azure/scope/cluster.go +++ b/azure/scope/cluster.go @@ -153,6 +153,15 @@ func (s *ClusterScope) PublicIPSpecs() []azure.PublicIPSpec { publicIPSpecs = append(publicIPSpecs, nodeOutboundIPSpecs...) + if s.AzureCluster.Spec.BastionSpec.AzureBastion != nil { + // public IP for Azure Bastion. + azureBastionPublicIP := azure.PublicIPSpec{ + Name: s.AzureCluster.Spec.BastionSpec.AzureBastion.PublicIP.Name, + DNSName: s.AzureCluster.Spec.BastionSpec.AzureBastion.PublicIP.DNSName, + } + publicIPSpecs = append(publicIPSpecs, azureBastionPublicIP) + } + return publicIPSpecs } @@ -236,7 +245,7 @@ func (s *ClusterScope) NSGSpecs() []azure.NSGSpec { // SubnetSpecs returns the subnets specs. func (s *ClusterScope) SubnetSpecs() []azure.SubnetSpec { - return []azure.SubnetSpec{ + subnetSpecs := []azure.SubnetSpec{ { Name: s.ControlPlaneSubnet().Name, CIDRs: s.ControlPlaneSubnet().CIDRBlocks, @@ -254,6 +263,20 @@ func (s *ClusterScope) SubnetSpecs() []azure.SubnetSpec { Role: s.NodeSubnet().Role, }, } + + if s.AzureCluster.Spec.BastionSpec.AzureBastion != nil { + azureBastionSubnet := s.AzureCluster.Spec.BastionSpec.AzureBastion.Subnet + subnetSpecs = append(subnetSpecs, azure.SubnetSpec{ + Name: azureBastionSubnet.Name, + CIDRs: azureBastionSubnet.CIDRBlocks, + VNetName: s.Vnet().Name, + SecurityGroupName: azureBastionSubnet.SecurityGroup.Name, + RouteTableName: azureBastionSubnet.RouteTable.Name, + Role: azureBastionSubnet.Role, + }) + } + + return subnetSpecs } // VNetSpec returns the virtual network spec. @@ -285,6 +308,21 @@ func (s *ClusterScope) PrivateDNSSpec() *azure.PrivateDNSSpec { return spec } +// BastionSpec returns the bastion spec. +func (s *ClusterScope) BastionSpec() azure.BastionSpec { + var ret azure.BastionSpec + if s.AzureCluster.Spec.BastionSpec.AzureBastion != nil { + ret.AzureBastion = &azure.AzureBastionSpec{ + Name: s.AzureCluster.Spec.BastionSpec.AzureBastion.Name, + SubnetSpec: s.AzureCluster.Spec.BastionSpec.AzureBastion.Subnet, + PublicIPName: s.AzureCluster.Spec.BastionSpec.AzureBastion.PublicIP.Name, + VNetName: s.Vnet().Name, + } + } + + return ret +} + // Vnet returns the cluster Vnet. func (s *ClusterScope) Vnet() *infrav1.VnetSpec { return &s.AzureCluster.Spec.NetworkSpec.Vnet diff --git a/azure/scope/machine.go b/azure/scope/machine.go index 9ca584a5629..ee4d4a562fb 100644 --- a/azure/scope/machine.go +++ b/azure/scope/machine.go @@ -207,17 +207,6 @@ func (m *MachineScope) DiskSpecs() []azure.DiskSpec { return disks } -// BastionSpecs returns the bastion specs. -func (m *MachineScope) BastionSpecs() []azure.BastionSpec { - spec := azure.BastionSpec{ - Name: azure.GenerateOSDiskName(m.Name()), - SubnetName: m.Subnet().Name, - PublicIPName: azure.GenerateNodePublicIPName(azure.GenerateNICName(m.Name())), - VNetName: m.Vnet().Name, - } - return []azure.BastionSpec{spec} -} - // RoleAssignmentSpecs returns the role assignment specs. func (m *MachineScope) RoleAssignmentSpecs() []azure.RoleAssignmentSpec { if m.AzureMachine.Spec.Identity == infrav1.VMIdentitySystemAssigned { diff --git a/azure/services/bastionhosts/azurebastion.go b/azure/services/bastionhosts/azurebastion.go new file mode 100644 index 00000000000..8b143498e33 --- /dev/null +++ b/azure/services/bastionhosts/azurebastion.go @@ -0,0 +1,101 @@ +/* +Copyright 2021 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 bastionhosts + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-06-01/network" + "github.com/Azure/go-autorest/autorest/to" + "github.com/pkg/errors" + + infrav1 "sigs.k8s.io/cluster-api-provider-azure/api/v1alpha4" + "sigs.k8s.io/cluster-api-provider-azure/azure" + "sigs.k8s.io/cluster-api-provider-azure/azure/converters" +) + +func (s *Service) ensureAzureBastion(ctx context.Context, azureBastionSpec azure.AzureBastionSpec) error { + s.Scope.V(2).Info("getting azure bastion public IP", "publicIP", azureBastionSpec.PublicIPName) + publicIP, err := s.publicIPsClient.Get(ctx, s.Scope.ResourceGroup(), azureBastionSpec.PublicIPName) + if err != nil { + return errors.Wrap(err, "failed to get public IP for azure bastion") + } + + s.Scope.V(2).Info("getting azure bastion subnet", "subnet", azureBastionSpec.SubnetSpec) + subnet, err := s.subnetsClient.Get(ctx, s.Scope.ResourceGroup(), azureBastionSpec.VNetName, azureBastionSpec.SubnetSpec.Name) + if err != nil { + return errors.Wrap(err, "failed to get subnet for azure bastion") + } + + s.Scope.V(2).Info("creating bastion host", "bastion", azureBastionSpec.Name) + bastionHostIPConfigName := fmt.Sprintf("%s-%s", azureBastionSpec.Name, "bastionIP") + err = s.client.CreateOrUpdate( + ctx, + s.Scope.ResourceGroup(), + azureBastionSpec.Name, + network.BastionHost{ + Name: to.StringPtr(azureBastionSpec.Name), + Location: to.StringPtr(s.Scope.Location()), + Tags: converters.TagsToMap(infrav1.Build(infrav1.BuildParams{ + ClusterName: s.Scope.ClusterName(), + Lifecycle: infrav1.ResourceLifecycleOwned, + Name: to.StringPtr(azureBastionSpec.Name), + Role: to.StringPtr("Bastion"), + })), + BastionHostPropertiesFormat: &network.BastionHostPropertiesFormat{ + DNSName: to.StringPtr(fmt.Sprintf("%s-bastion", strings.ToLower(azureBastionSpec.Name))), + IPConfigurations: &[]network.BastionHostIPConfiguration{ + { + Name: to.StringPtr(bastionHostIPConfigName), + BastionHostIPConfigurationPropertiesFormat: &network.BastionHostIPConfigurationPropertiesFormat{ + Subnet: &network.SubResource{ + ID: subnet.ID, + }, + PublicIPAddress: &network.SubResource{ + ID: publicIP.ID, + }, + PrivateIPAllocationMethod: network.Dynamic, + }, + }, + }, + }, + }, + ) + if err != nil { + return errors.Wrap(err, "cannot create Azure Bastion") + } + + s.Scope.V(2).Info("successfully created bastion host", "bastion", azureBastionSpec.Name) + return nil +} + +func (s *Service) ensureAzureBastionDeleted(ctx context.Context, azureBastionSpec azure.AzureBastionSpec) error { + s.Scope.V(2).Info("deleting bastion host", "bastion", azureBastionSpec.Name) + + err := s.client.Delete(ctx, s.Scope.ResourceGroup(), azureBastionSpec.Name) + if err != nil && azure.ResourceNotFound(err) { + // Resource already deleted, all good. + } else if err != nil { + return errors.Wrapf(err, "failed to delete Azure Bastion %s in resource group %s", azureBastionSpec.Name, s.Scope.ResourceGroup()) + } + + s.Scope.V(2).Info("successfully deleted bastion host", "bastion", azureBastionSpec.Name) + + return nil +} diff --git a/azure/services/bastionhosts/bastionhosts.go b/azure/services/bastionhosts/bastionhosts.go index 4b87eaca1a1..1effaef83e8 100644 --- a/azure/services/bastionhosts/bastionhosts.go +++ b/azure/services/bastionhosts/bastionhosts.go @@ -18,18 +18,12 @@ package bastionhosts import ( "context" - "fmt" - "strings" - "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-06-01/network" - "github.com/Azure/go-autorest/autorest/to" "github.com/go-logr/logr" "github.com/pkg/errors" - infrav1 "sigs.k8s.io/cluster-api-provider-azure/api/v1alpha4" "sigs.k8s.io/cluster-api-provider-azure/azure" - "sigs.k8s.io/cluster-api-provider-azure/azure/converters" "sigs.k8s.io/cluster-api-provider-azure/azure/services/publicips" "sigs.k8s.io/cluster-api-provider-azure/azure/services/subnets" "sigs.k8s.io/cluster-api-provider-azure/util/tele" @@ -40,7 +34,7 @@ type BastionScope interface { logr.Logger azure.ClusterDescriber azure.NetworkDescriber - BastionSpecs() []azure.BastionSpec + BastionSpec() azure.BastionSpec } // Service provides operations on azure resources @@ -66,71 +60,14 @@ func (s *Service) Reconcile(ctx context.Context) error { ctx, span := tele.Tracer().Start(ctx, "bastionhosts.Service.Reconcile") defer span.End() - for _, bastionSpec := range s.Scope.BastionSpecs() { - s.Scope.V(2).Info("getting subnet in vnet", "subnet", bastionSpec.SubnetName, "vNet", bastionSpec.VNetName) - subnet, err := s.subnetsClient.Get(ctx, s.Scope.ResourceGroup(), bastionSpec.VNetName, bastionSpec.SubnetName) + azureBastionSpec := s.Scope.BastionSpec().AzureBastion + if azureBastionSpec != nil { + err := s.ensureAzureBastion(ctx, *azureBastionSpec) if err != nil { - return errors.Wrap(err, "failed to get subnet") + return errors.Wrap(err, "error creating Azure Bastion") } - s.Scope.V(2).Info("successfully got subnet in vnet", "subnet", bastionSpec.SubnetName, "vNet", bastionSpec.VNetName) - - s.Scope.V(2).Info("checking if public ip exist otherwise will try to create", "publicIP", bastionSpec.PublicIPName) - publicIP, err := s.publicIPsClient.Get(ctx, s.Scope.ResourceGroup(), bastionSpec.PublicIPName) - if err != nil && azure.ResourceNotFound(err) { - iperr := s.createBastionPublicIP(ctx, bastionSpec.PublicIPName) - if iperr != nil { - return errors.Wrap(iperr, "failed to create bastion publicIP") - } - var errPublicIP error - publicIP, errPublicIP = s.publicIPsClient.Get(ctx, s.Scope.ResourceGroup(), bastionSpec.PublicIPName) - if errPublicIP != nil { - return errors.Wrap(errPublicIP, "failed to get created publicIP") - } - } else if err != nil { - return errors.Wrap(err, "failed to get existing publicIP") - } - s.Scope.V(2).Info("successfully got public ip", "publicIP", bastionSpec.PublicIPName) - - s.Scope.V(2).Info("creating bastion host", "bastion", bastionSpec.Name) - bastionHostIPConfigName := fmt.Sprintf("%s-%s", bastionSpec.Name, "bastionIP") - err = s.client.CreateOrUpdate( - ctx, - s.Scope.ResourceGroup(), - bastionSpec.Name, - network.BastionHost{ - Name: to.StringPtr(bastionSpec.Name), - Location: to.StringPtr(s.Scope.Location()), - Tags: converters.TagsToMap(infrav1.Build(infrav1.BuildParams{ - ClusterName: s.Scope.ClusterName(), - Lifecycle: infrav1.ResourceLifecycleOwned, - Name: to.StringPtr(bastionSpec.Name), - Role: to.StringPtr("Bastion"), - })), - BastionHostPropertiesFormat: &network.BastionHostPropertiesFormat{ - DNSName: to.StringPtr(fmt.Sprintf("%s-bastion", strings.ToLower(bastionSpec.Name))), - IPConfigurations: &[]network.BastionHostIPConfiguration{ - { - Name: to.StringPtr(bastionHostIPConfigName), - BastionHostIPConfigurationPropertiesFormat: &network.BastionHostIPConfigurationPropertiesFormat{ - Subnet: &network.SubResource{ - ID: subnet.ID, - }, - PublicIPAddress: &network.SubResource{ - ID: publicIP.ID, - }, - PrivateIPAllocationMethod: network.Static, - }, - }, - }, - }, - }, - ) - if err != nil { - return errors.Wrap(err, "cannot create bastion host") - } - - s.Scope.V(2).Info("successfully created bastion host", "bastion", bastionSpec.Name) } + return nil } @@ -139,44 +76,12 @@ func (s *Service) Delete(ctx context.Context) error { ctx, span := tele.Tracer().Start(ctx, "bastionhosts.Service.Delete") defer span.End() - for _, bastionSpec := range s.Scope.BastionSpecs() { - - s.Scope.V(2).Info("deleting bastion host", "bastion", bastionSpec.Name) - - err := s.client.Delete(ctx, s.Scope.ResourceGroup(), bastionSpec.Name) - if err != nil && azure.ResourceNotFound(err) { - // already deleted - continue - } + azureBastionSpec := s.Scope.BastionSpec().AzureBastion + if azureBastionSpec != nil { + err := s.ensureAzureBastionDeleted(ctx, *azureBastionSpec) if err != nil { - return errors.Wrapf(err, "failed to delete Bastion Host %s in resource group %s", bastionSpec.Name, s.Scope.ResourceGroup()) + return errors.Wrap(err, "error deleting Azure Bastion") } - - s.Scope.V(2).Info("successfully deleted bastion host", "bastion", bastionSpec.Name) } return nil } - -func (s *Service) createBastionPublicIP(ctx context.Context, ipName string) error { - ctx, span := tele.Tracer().Start(ctx, "bastionhosts.Service.createBastionPublicIP") - defer span.End() - - s.Scope.V(2).Info("creating bastion public IP", "public IP", ipName) - return s.publicIPsClient.CreateOrUpdate( - ctx, - s.Scope.ResourceGroup(), - ipName, - network.PublicIPAddress{ - Sku: &network.PublicIPAddressSku{Name: network.PublicIPAddressSkuNameStandard}, - Name: to.StringPtr(ipName), - Location: to.StringPtr(s.Scope.Location()), - PublicIPAddressPropertiesFormat: &network.PublicIPAddressPropertiesFormat{ - PublicIPAddressVersion: network.IPv4, - PublicIPAllocationMethod: network.Static, - DNSSettings: &network.PublicIPAddressDNSSettings{ - DomainNameLabel: to.StringPtr(strings.ToLower(ipName)), - }, - }, - }, - ) -} diff --git a/azure/services/bastionhosts/bastionhosts_test.go b/azure/services/bastionhosts/bastionhosts_test.go index 3a5b56db7d3..645e642a63c 100644 --- a/azure/services/bastionhosts/bastionhosts_test.go +++ b/azure/services/bastionhosts/bastionhosts_test.go @@ -22,6 +22,8 @@ import ( "testing" . "github.com/onsi/gomega" + + "sigs.k8s.io/cluster-api-provider-azure/api/v1alpha4" "sigs.k8s.io/cluster-api-provider-azure/azure" mock_bastionhosts "sigs.k8s.io/cluster-api-provider-azure/azure/services/bastionhosts/mocks_bastionhosts" "sigs.k8s.io/cluster-api-provider-azure/azure/services/publicips/mock_publicips" @@ -58,11 +60,13 @@ func TestReconcileBastionHosts(t *testing.T) { mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastion", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastion", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) @@ -79,11 +83,13 @@ func TestReconcileBastionHosts(t *testing.T) { mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastion", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastion", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) @@ -102,11 +108,13 @@ func TestReconcileBastionHosts(t *testing.T) { mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastion", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastion", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) @@ -127,11 +135,13 @@ func TestReconcileBastionHosts(t *testing.T) { mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastion", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastion", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) @@ -151,11 +161,13 @@ func TestReconcileBastionHosts(t *testing.T) { mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastion", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastion", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) @@ -179,11 +191,13 @@ func TestReconcileBastionHosts(t *testing.T) { mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastion", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastion", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) @@ -199,17 +213,19 @@ func TestReconcileBastionHosts(t *testing.T) { }, { name: "fail to create a bastion", - expectedError: "cannot create bastion host: #: Internal Server Error: StatusCode=500", + expectedError: "error creating Azure Bastion: cannot create Azure Bastion: #: Internal Server Error: StatusCode=500", expect: func(s *mock_bastionhosts.MockBastionScopeMockRecorder, m *mock_bastionhosts.MockclientMockRecorder, mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastion", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastion", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) @@ -217,8 +233,8 @@ func TestReconcileBastionHosts(t *testing.T) { s.Location().AnyTimes().Return("fake-location") s.ClusterName().AnyTimes().Return("fake-cluster") gomock.InOrder( - mSubnet.Get(gomockinternal.AContext(), "my-rg", "my-vnet", "my-subnet").Return(network.Subnet{}, nil), mPublicIP.Get(gomockinternal.AContext(), "my-rg", "my-publicip").Return(network.PublicIPAddress{}, nil), + mSubnet.Get(gomockinternal.AContext(), "my-rg", "my-vnet", "my-subnet").Return(network.Subnet{}, nil), m.CreateOrUpdate(gomockinternal.AContext(), "my-rg", "my-bastion", gomock.AssignableToTypeOf(network.BastionHost{})).Return(autorest.NewErrorWithResponse("", "", &http.Response{StatusCode: 500}, "Internal Server Error")), ) }, @@ -276,23 +292,18 @@ func TestDeleteBastionHost(t *testing.T) { mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastionhost", - VNetName: "my-vnet", - SubnetName: "my-subnet", - PublicIPName: "my-publicip", - }, - { - Name: "my-bastionhost1", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastionhost", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) s.ResourceGroup().AnyTimes().Return("my-rg") m.Delete(gomockinternal.AContext(), "my-rg", "my-bastionhost") - m.Delete(gomockinternal.AContext(), "my-rg", "my-bastionhost1") }, }, { @@ -303,17 +314,13 @@ func TestDeleteBastionHost(t *testing.T) { mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastionhost", - VNetName: "my-vnet", - SubnetName: "my-subnet", - PublicIPName: "my-publicip", - }, - { - Name: "my-bastionhost1", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastionhost", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) @@ -325,17 +332,19 @@ func TestDeleteBastionHost(t *testing.T) { }, { name: "bastion host deletion fails", - expectedError: "failed to delete Bastion Host my-bastionhost in resource group my-rg: #: Internal Server Error: StatusCode=500", + expectedError: "error deleting Azure Bastion: failed to delete Azure Bastion my-bastionhost in resource group my-rg: #: Internal Server Error: StatusCode=500", expect: func(s *mock_bastionhosts.MockBastionScopeMockRecorder, m *mock_bastionhosts.MockclientMockRecorder, mSubnet *mock_subnets.MockClientMockRecorder, mPublicIP *mock_publicips.MockClientMockRecorder) { s.V(gomock.AssignableToTypeOf(2)).AnyTimes().Return(klogr.New()) - s.BastionSpecs().Return([]azure.BastionSpec{ - { - Name: "my-bastionhost", - VNetName: "my-vnet", - SubnetName: "my-subnet", + s.BastionSpec().Return(azure.BastionSpec{ + AzureBastion: &azure.AzureBastionSpec{ + Name: "my-bastionhost", + VNetName: "my-vnet", + SubnetSpec: v1alpha4.SubnetSpec{ + Name: "my-subnet", + }, PublicIPName: "my-publicip", }, }) diff --git a/azure/services/bastionhosts/mocks_bastionhosts/bastionhosts_mock.go b/azure/services/bastionhosts/mocks_bastionhosts/bastionhosts_mock.go index 31b1af07da1..c230ca09465 100644 --- a/azure/services/bastionhosts/mocks_bastionhosts/bastionhosts_mock.go +++ b/azure/services/bastionhosts/mocks_bastionhosts/bastionhosts_mock.go @@ -137,18 +137,18 @@ func (mr *MockBastionScopeMockRecorder) BaseURI() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BaseURI", reflect.TypeOf((*MockBastionScope)(nil).BaseURI)) } -// BastionSpecs mocks base method. -func (m *MockBastionScope) BastionSpecs() []azure.BastionSpec { +// BastionSpec mocks base method. +func (m *MockBastionScope) BastionSpec() azure.BastionSpec { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BastionSpecs") - ret0, _ := ret[0].([]azure.BastionSpec) + ret := m.ctrl.Call(m, "BastionSpec") + ret0, _ := ret[0].(azure.BastionSpec) return ret0 } -// BastionSpecs indicates an expected call of BastionSpecs. -func (mr *MockBastionScopeMockRecorder) BastionSpecs() *gomock.Call { +// BastionSpec indicates an expected call of BastionSpec. +func (mr *MockBastionScopeMockRecorder) BastionSpec() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BastionSpecs", reflect.TypeOf((*MockBastionScope)(nil).BastionSpecs)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BastionSpec", reflect.TypeOf((*MockBastionScope)(nil).BastionSpec)) } // ClientID mocks base method. diff --git a/azure/types.go b/azure/types.go index aaeb080d1bf..81573a31864 100644 --- a/azure/types.go +++ b/azure/types.go @@ -138,10 +138,15 @@ type VMSpec struct { SecurityProfile *infrav1.SecurityProfile } -// BastionSpec defines the specification for bastion host. +// BastionSpec defines the specification for the generic bastion feature. type BastionSpec struct { + AzureBastion *AzureBastionSpec +} + +// AzureBastionSpec defines the specification for azure bastion feature. +type AzureBastionSpec struct { //nolint Name string - SubnetName string + SubnetSpec infrav1.SubnetSpec PublicIPName string VNetName string } diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusters.yaml index 0e28d3df076..5621d2d40d2 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusters.yaml @@ -370,6 +370,115 @@ spec: azureEnvironment: description: 'AzureEnvironment is the name of the AzureCloud to be used. The default value that would be used by most users is "AzurePublicCloud", other values are: - ChinaCloud: "AzureChinaCloud" - GermanCloud: "AzureGermanCloud" - PublicCloud: "AzurePublicCloud" - USGovernmentCloud: "AzureUSGovernmentCloud"' type: string + bastionSpec: + description: BastionSpec encapsulates all things related to the Bastions in the cluster. + properties: + azureBastion: + description: AzureBastion specifies how the Azure Bastion cloud component should be configured. + properties: + name: + type: string + publicIP: + description: PublicIPSpec defines the inputs to create an Azure public IP address. + properties: + dnsName: + type: string + name: + type: string + required: + - name + type: object + subnet: + description: SubnetSpec configures an Azure subnet. + properties: + cidrBlocks: + description: CIDRBlocks defines the subnet's address space, specified as one or more address prefixes in CIDR notation. + items: + type: string + type: array + id: + description: ID defines a unique identifier to reference this resource. + type: string + name: + description: Name defines a name for the subnet resource. + type: string + role: + description: Role defines the subnet role (eg. Node, ControlPlane) + type: string + routeTable: + description: RouteTable defines the route table that should be attached to this subnet. + properties: + id: + type: string + name: + type: string + type: object + securityGroup: + description: SecurityGroup defines the NSG (network security group) that should be attached to this subnet. + properties: + id: + type: string + name: + type: string + securityRules: + description: SecurityRules is a slice of Azure security rules for security groups. + items: + description: SecurityRule defines an Azure security rule for security groups. + properties: + description: + description: A description for this rule. Restricted to 140 chars. + type: string + destination: + description: Destination is the destination address prefix. CIDR or destination IP range. Asterix '*' can also be used to match all source IPs. Default tags such as 'VirtualNetwork', 'AzureLoadBalancer' and 'Internet' can also be used. + type: string + destinationPorts: + description: DestinationPorts specifies the destination port or range. Integer or range between 0 and 65535. Asterix '*' can also be used to match all ports. + type: string + direction: + description: Direction indicates whether the rule applies to inbound, or outbound traffic. "Inbound" or "Outbound". + enum: + - Inbound + - Outbound + type: string + name: + description: Name is a unique name within the network security group. + type: string + priority: + description: Priority is a number between 100 and 4096. Each rule should have a unique value for priority. Rules are processed in priority order, with lower numbers processed before higher numbers. Once traffic matches a rule, processing stops. + format: int32 + type: integer + protocol: + description: Protocol specifies the protocol type. "Tcp", "Udp", "Icmp", or "*". + enum: + - Tcp + - Udp + - Icmp + - '*' + type: string + source: + description: Source specifies the CIDR or source IP range. Asterix '*' can also be used to match all source IPs. Default tags such as 'VirtualNetwork', 'AzureLoadBalancer' and 'Internet' can also be used. If this is an ingress rule, specifies where network traffic originates from. + type: string + sourcePorts: + description: SourcePorts specifies source port or range. Integer or range between 0 and 65535. Asterix '*' can also be used to match all ports. + type: string + required: + - description + - direction + - name + - protocol + type: object + type: array + tags: + additionalProperties: + type: string + description: Tags defines a map of tags. + type: object + type: object + required: + - name + type: object + type: object + type: object cloudProviderConfigOverrides: description: 'CloudProviderConfigOverrides is an optional set of configuration values that can be overridden in azure cloud provider config. This is only a subset of options that are available in azure cloud provider config. Some values for the cloud provider config are inferred from other parts of cluster api provider azure spec, and may not be available for overrides. See: https://kubernetes-sigs.github.io/cloud-provider-azure/install/configs Note: All cloud provider config values can be customized by creating the secret beforehand. CloudProviderConfigOverrides is only used when the secret is managed by the Azure Provider.' properties: diff --git a/controllers/azurecluster_reconciler.go b/controllers/azurecluster_reconciler.go index 436e7820102..7bf1ece8821 100644 --- a/controllers/azurecluster_reconciler.go +++ b/controllers/azurecluster_reconciler.go @@ -24,6 +24,7 @@ import ( "sigs.k8s.io/cluster-api-provider-azure/azure" "sigs.k8s.io/cluster-api-provider-azure/azure/scope" + "sigs.k8s.io/cluster-api-provider-azure/azure/services/bastionhosts" "sigs.k8s.io/cluster-api-provider-azure/azure/services/groups" "sigs.k8s.io/cluster-api-provider-azure/azure/services/loadbalancers" "sigs.k8s.io/cluster-api-provider-azure/azure/services/privatedns" @@ -47,6 +48,7 @@ type azureClusterService struct { publicIPSvc azure.Reconciler loadBalancerSvc azure.Reconciler privateDNSSvc azure.Reconciler + bastionSvc azure.Reconciler skuCache *resourceskus.Cache } @@ -67,6 +69,7 @@ func newAzureClusterService(scope *scope.ClusterScope) (*azureClusterService, er publicIPSvc: publicips.New(scope), loadBalancerSvc: loadbalancers.New(scope), privateDNSSvc: privatedns.New(scope), + bastionSvc: bastionhosts.New(scope), skuCache: skuCache, }, nil } @@ -117,6 +120,10 @@ func (s *azureClusterService) Reconcile(ctx context.Context) error { return errors.Wrap(err, "failed to reconcile private dns") } + if err := s.bastionSvc.Reconcile(ctx); err != nil { + return errors.Wrap(err, "failed to reconcile bastion") + } + return nil } @@ -155,6 +162,10 @@ func (s *azureClusterService) Delete(ctx context.Context) error { return errors.Wrap(err, "failed to delete virtual network") } + if err := s.bastionSvc.Delete(ctx); err != nil { + return errors.Wrap(err, "failed to delete bastion") + } + } else { return errors.Wrap(err, "failed to delete resource group") } diff --git a/controllers/azurecluster_reconciler_test.go b/controllers/azurecluster_reconciler_test.go index f4e807388b4..45af03e1f59 100644 --- a/controllers/azurecluster_reconciler_test.go +++ b/controllers/azurecluster_reconciler_test.go @@ -33,7 +33,7 @@ import ( gomockinternal "sigs.k8s.io/cluster-api-provider-azure/internal/test/matchers/gomock" ) -type expect func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder) +type expect func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder, bastion *mocks.MockReconcilerMockRecorder) func TestAzureClusterReconcilerDelete(t *testing.T) { cases := map[string]struct { @@ -42,21 +42,21 @@ func TestAzureClusterReconcilerDelete(t *testing.T) { }{ "Resource Group is deleted successfully": { expectedError: "", - expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder) { + expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder, bastion *mocks.MockReconcilerMockRecorder) { gomock.InOrder( grp.Delete(gomockinternal.AContext()).Return(nil)) }, }, "Resource Group delete fails": { expectedError: "failed to delete resource group: internal error", - expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder) { + expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder, bastion *mocks.MockReconcilerMockRecorder) { gomock.InOrder( grp.Delete(gomockinternal.AContext()).Return(errors.New("internal error"))) }, }, "Resource Group not owned by cluster": { expectedError: "", - expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder) { + expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder, bastion *mocks.MockReconcilerMockRecorder) { gomock.InOrder( grp.Delete(gomockinternal.AContext()).Return(azure.ErrNotOwned), dns.Delete(gomockinternal.AContext()), @@ -66,12 +66,13 @@ func TestAzureClusterReconcilerDelete(t *testing.T) { rt.Delete(gomockinternal.AContext()), sg.Delete(gomockinternal.AContext()), vnet.Delete(gomockinternal.AContext()), + bastion.Delete(gomockinternal.AContext()), ) }, }, "Load Balancer delete fails": { expectedError: "failed to delete load balancer: some error happened", - expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder) { + expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder, bastion *mocks.MockReconcilerMockRecorder) { gomock.InOrder( grp.Delete(gomockinternal.AContext()).Return(azure.ErrNotOwned), dns.Delete(gomockinternal.AContext()), @@ -81,7 +82,7 @@ func TestAzureClusterReconcilerDelete(t *testing.T) { }, "Route table delete fails": { expectedError: "failed to delete route table: some error happened", - expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder) { + expect: func(grp *mocks.MockReconcilerMockRecorder, vnet *mocks.MockReconcilerMockRecorder, sg *mocks.MockReconcilerMockRecorder, rt *mocks.MockReconcilerMockRecorder, sn *mocks.MockReconcilerMockRecorder, pip *mocks.MockReconcilerMockRecorder, lb *mocks.MockReconcilerMockRecorder, dns *mocks.MockReconcilerMockRecorder, bastion *mocks.MockReconcilerMockRecorder) { gomock.InOrder( grp.Delete(gomockinternal.AContext()).Return(azure.ErrNotOwned), dns.Delete(gomockinternal.AContext()), @@ -110,8 +111,9 @@ func TestAzureClusterReconcilerDelete(t *testing.T) { publicIPMock := mocks.NewMockReconciler(mockCtrl) lbMock := mocks.NewMockReconciler(mockCtrl) dnsMock := mocks.NewMockReconciler(mockCtrl) + bastionMock := mocks.NewMockReconciler(mockCtrl) - tc.expect(groupsMock.EXPECT(), vnetMock.EXPECT(), sgMock.EXPECT(), rtMock.EXPECT(), subnetsMock.EXPECT(), publicIPMock.EXPECT(), lbMock.EXPECT(), dnsMock.EXPECT()) + tc.expect(groupsMock.EXPECT(), vnetMock.EXPECT(), sgMock.EXPECT(), rtMock.EXPECT(), subnetsMock.EXPECT(), publicIPMock.EXPECT(), lbMock.EXPECT(), dnsMock.EXPECT(), bastionMock.EXPECT()) s := &azureClusterService{ scope: &scope.ClusterScope{ @@ -125,6 +127,7 @@ func TestAzureClusterReconcilerDelete(t *testing.T) { publicIPSvc: publicIPMock, loadBalancerSvc: lbMock, privateDNSSvc: dnsMock, + bastionSvc: bastionMock, skuCache: resourceskus.NewStaticCache([]compute.ResourceSku{}, ""), } diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 8e05c1d38f8..631f0c17848 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -25,6 +25,7 @@ - [Spot Virtual Machines](./topics/spot-vms.md) - [Virtual Networks](./topics/custom-vnet.md) - [Windows](./topics/windows.md) + - [SSH Access to nodes](./topics/ssh-access.md) - [Development](./developers/development.md) - [Kubernetes Developers](./developers/kubernetes-developers.md) - [Releasing](./developers/releasing.md) diff --git a/docs/book/src/topics/ssh-access.md b/docs/book/src/topics/ssh-access.md new file mode 100644 index 00000000000..6d970bdc857 --- /dev/null +++ b/docs/book/src/topics/ssh-access.md @@ -0,0 +1,171 @@ +# SSH access to nodes + +This document describes how to get SSH access to virtual machines being part of a CAPZ cluster. + +In order to get SSH access to a Virtual Machine on Azure, two requirements have to be met: + +- get network-level access to the SSH service; +- get authentication sorted. + +This documents describe some possible strategies to fulfil both requirements. + +## Network Access + +### Default behavior + +By default, `control plane` VMs have SSH access allowed from any source in their `Network Security Group`s. Also by default, +VMs don't have a public IP address assigned. + +To get SSH access to one of the `Control Plane` VMs you can use the `API Load Balancer`'s IP, because by default an `Inbound NAT Rule` +is created to route traffic coming to the load balancer on TCP port 22 (the SSH port) to one of the nodes with role `master` in the workload cluster. + +This of course works only for clusters that are using a `Public` Load Balancer. + +In order to reach all other VMs, you can use the NATted control plane VM as a bastion host and use the private IP +address for the other nodes. + +For example, let's consider this CAPZ cluster (using a Public Load Balancer) with two nodes: + +``` +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +test1-control-plane-cn9lm Ready master 111m v1.18.16 10.0.0.4 Ubuntu 18.04.5 LTS 5.4.0-1039-azure containerd://1.4.3 +test1-md-0-scctm Ready 109m v1.18.16 10.1.0.4 Ubuntu 18.04.5 LTS 5.4.0-1039-azure containerd://1.4.3 +``` + +You can SSH to the control plane node using the load balancer's public DNS name: + +``` +$ kubectl get azurecluster test1 -o json| jq .spec.networkSpec.apiServerLB.frontendIPs[0].publicIP.dnsName +test1-21192f78.eastus.cloudapp.azure.com + +$ ssh username@test1-21192f78.eastus.cloudapp.azure.com hostname +test1-control-plane-cn9lm +``` + +As you can see, the Load Balancer routed the request to node `test1-control-plane-cn9lm` that is the only node with role `master` in this workload cluster. + +In order to SSH to node 'test1-md-0-scctm', you can use the other node as a bastion: + +``` +$ ssh -J username@test1-21192f78.eastus.cloudapp.azure.com username@10.1.0.4 hostname +test1-md-0-scctm +``` + +Clusters using an `Internal` Load Balancer (private clusters) can't use this approach. Network-level SSH access to those clusters have to be made on the private IP address of VMs +by first getting access to the Virtual Network. How to do that is out of the scope of this document. +A possible alternative that works for private clusters as well is described in the next paragraph. + +### Azure Bastion + +A possible alternative to the process described above is to use the [`Azure Bastion`](https://azure.microsoft.com/en-us/services/azure-bastion/) feature. +This approach works the same way for workload clusters using either type of `Load Balancers`. + +In order to enable `Azure Bastion` on a CAPZ workload cluster, edit the `AzureCluster` CR and set the `spec/bastionSpec/azureBastion` field. +It is enough to set the field's value to the empty object `{}` and the default configuration settings will be used while deploying the `Azure Bastion`. + +For example this is an `AzureCluster` CR with the `Azure Bastion` feature enabled: + +``` +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 +kind: AzureCluster +metadata: + name: test1 + namespace: default +spec: + bastionSpec: + azureBastion: {} + ... +``` + +Once the `Azure Bastion` is deployed, it will be possible to SSH to any of the cluster VMs through the +`Azure Portal`. Please follow the [official documentation](https://docs.microsoft.com/en-us/azure/bastion/bastion-overview) +for a deeper explanation on how to do that. + +#### Advanced settings + +When the `AzureBastion` feature is enabled in a CAPZ cluster, 3 new resources will be deployed in the resource group: + +- The `Azure Bastion` resource; +- A subnet named `AzureBastionSubnet` (the name is mandatory and can't be changed); +- A public `IP address`. + +The default values for the new resources should work for most use cases, but if you need to customize them you can +provide your own values. Here is a detailed example: + +``` +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 +kind: AzureCluster +metadata: + name: test1 + namespace: default +spec: + bastionSpec: + azureBastion: + name: "..." // The name of the Azure Bastion, defaults to '-azure-bastion' + subnet: + name: "..." // The name of the Subnet. The only supported name is `AzureBastionSubnet` (this is an Azure limitation). + securityGroup: {} // No security group is assigned by default. You can choose to have one created and assigned by defining it. + publicIP: + "name": "..." // The name of the Public IP, defaults to '-azure-bastion-pip'. +``` + +If you specify a security group to be associated with the Azure Bastion subnet, it needs to have some networking rules defined or +the `Azure Bastion` resource creation will fail. Please refer to [the documentation](https://docs.microsoft.com/en-us/azure/bastion/bastion-nsg) for more details. + +## Authentication + +With the networking part sorted, we still have to work out a way of authenticating to the VMs via SSH. + +### Provisioning SSH keys using Machine Templates + +In order to add an SSH authorized key for user `username` and provide `sudo` access to the `control plane` VMs, you can adjust the `KubeadmControlPlane` CR +as in the following example: + +``` +apiVersion: controlplane.cluster.x-k8s.io/v1alpha3 +kind: KubeadmControlPlane +... +spec: + ... + kubeadmConfigSpec: + ... + users: + - name: username + sshAuthorizedKeys: + - "ssh-rsa AAAA..." + files: + - content: "username ALL = (ALL) NOPASSWD: ALL" + owner: root:root + path: /etc/sudoers.d/username + permissions: "0440" + ... +``` + +Similarly, you can achieve the same result for `Machine Deployments` by customizing the `KubeadmConfigTemplate` CR: + +``` +apiVersion: bootstrap.cluster.x-k8s.io/v1alpha3 +kind: KubeadmConfigTemplate +metadata: + name: test1-md-0 + namespace: default +spec: + template: + spec: + files: + ... + - content: "username ALL = (ALL) NOPASSWD: ALL" + owner: root:root + path: /etc/sudoers.d/username + permissions: "0440" + ... + users: + - name: username + sshAuthorizedKeys: + - "ssh-rsa AAAA..." +``` + +### Setting SSH keys or passwords using the Azure Portal + +An alternative way of gaining SSH access to VMs on Azure is to set the `password` or `authorized key` via the `Azure Portal`. +In the Portal, navigate to the `Virtual Machine` details page and find the `Reset password` function in the left pane. diff --git a/templates/cluster-template-azure-bastion.yaml b/templates/cluster-template-azure-bastion.yaml new file mode 100644 index 00000000000..aff6f076b0d --- /dev/null +++ b/templates/cluster-template-azure-bastion.yaml @@ -0,0 +1,197 @@ +apiVersion: cluster.x-k8s.io/v1alpha4 +kind: Cluster +metadata: + labels: + cni: calico + name: ${CLUSTER_NAME} + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 + controlPlaneRef: + apiVersion: controlplane.cluster.x-k8s.io/v1alpha4 + kind: KubeadmControlPlane + name: ${CLUSTER_NAME}-control-plane + infrastructureRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 + kind: AzureCluster + name: ${CLUSTER_NAME} +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 +kind: AzureCluster +metadata: + name: ${CLUSTER_NAME} + namespace: default +spec: + bastionSpec: + azureBastion: {} + location: ${AZURE_LOCATION} + networkSpec: + vnet: + name: ${AZURE_VNET_NAME:=${CLUSTER_NAME}-vnet} + resourceGroup: ${AZURE_RESOURCE_GROUP:=${CLUSTER_NAME}} + subscriptionID: ${AZURE_SUBSCRIPTION_ID} +--- +apiVersion: controlplane.cluster.x-k8s.io/v1alpha4 +kind: KubeadmControlPlane +metadata: + name: ${CLUSTER_NAME}-control-plane + namespace: default +spec: + kubeadmConfigSpec: + clusterConfiguration: + apiServer: + extraArgs: + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + extraVolumes: + - hostPath: /etc/kubernetes/azure.json + mountPath: /etc/kubernetes/azure.json + name: cloud-config + readOnly: true + timeoutForControlPlane: 20m + controllerManager: + extraArgs: + allocate-node-cidrs: "false" + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + cluster-name: ${CLUSTER_NAME} + extraVolumes: + - hostPath: /etc/kubernetes/azure.json + mountPath: /etc/kubernetes/azure.json + name: cloud-config + readOnly: true + etcd: + local: + dataDir: /var/lib/etcddisk/etcd + diskSetup: + filesystems: + - device: /dev/disk/azure/scsi1/lun0 + extraOpts: + - -E + - lazy_itable_init=1,lazy_journal_init=1 + filesystem: ext4 + label: etcd_disk + - device: ephemeral0.1 + filesystem: ext4 + label: ephemeral0 + replaceFS: ntfs + partitions: + - device: /dev/disk/azure/scsi1/lun0 + layout: true + overwrite: false + tableType: gpt + files: + - contentFrom: + secret: + key: control-plane-azure.json + name: ${CLUSTER_NAME}-control-plane-azure-json + owner: root:root + path: /etc/kubernetes/azure.json + permissions: "0644" + initConfiguration: + nodeRegistration: + kubeletExtraArgs: + azure-container-registry-config: /etc/kubernetes/azure.json + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + name: '{{ ds.meta_data["local_hostname"] }}' + joinConfiguration: + nodeRegistration: + kubeletExtraArgs: + azure-container-registry-config: /etc/kubernetes/azure.json + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + name: '{{ ds.meta_data["local_hostname"] }}' + mounts: + - - LABEL=etcd_disk + - /var/lib/etcddisk + machineTemplate: + infrastructureRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 + kind: AzureMachineTemplate + name: ${CLUSTER_NAME}-control-plane + replicas: ${CONTROL_PLANE_MACHINE_COUNT} + version: ${KUBERNETES_VERSION} +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 +kind: AzureMachineTemplate +metadata: + name: ${CLUSTER_NAME}-control-plane + namespace: default +spec: + template: + spec: + dataDisks: + - diskSizeGB: 256 + lun: 0 + nameSuffix: etcddisk + osDisk: + diskSizeGB: 128 + osType: Linux + sshPublicKey: ${AZURE_SSH_PUBLIC_KEY_B64:=""} + vmSize: ${AZURE_CONTROL_PLANE_MACHINE_TYPE} +--- +apiVersion: cluster.x-k8s.io/v1alpha4 +kind: MachineDeployment +metadata: + name: ${CLUSTER_NAME}-md-0 + namespace: default +spec: + clusterName: ${CLUSTER_NAME} + replicas: ${WORKER_MACHINE_COUNT} + selector: + matchLabels: null + template: + spec: + bootstrap: + configRef: + apiVersion: bootstrap.cluster.x-k8s.io/v1alpha4 + kind: KubeadmConfigTemplate + name: ${CLUSTER_NAME}-md-0 + clusterName: ${CLUSTER_NAME} + infrastructureRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 + kind: AzureMachineTemplate + name: ${CLUSTER_NAME}-md-0 + version: ${KUBERNETES_VERSION} +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 +kind: AzureMachineTemplate +metadata: + name: ${CLUSTER_NAME}-md-0 + namespace: default +spec: + template: + spec: + osDisk: + diskSizeGB: 128 + osType: Linux + sshPublicKey: ${AZURE_SSH_PUBLIC_KEY_B64:=""} + vmSize: ${AZURE_NODE_MACHINE_TYPE} +--- +apiVersion: bootstrap.cluster.x-k8s.io/v1alpha4 +kind: KubeadmConfigTemplate +metadata: + name: ${CLUSTER_NAME}-md-0 + namespace: default +spec: + template: + spec: + files: + - contentFrom: + secret: + key: worker-node-azure.json + name: ${CLUSTER_NAME}-md-0-azure-json + owner: root:root + path: /etc/kubernetes/azure.json + permissions: "0644" + joinConfiguration: + nodeRegistration: + kubeletExtraArgs: + azure-container-registry-config: /etc/kubernetes/azure.json + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + name: '{{ ds.meta_data["local_hostname"] }}' diff --git a/templates/cluster-template-private.yaml b/templates/cluster-template-private.yaml index cb42bc4acfd..8bddb487c7f 100644 --- a/templates/cluster-template-private.yaml +++ b/templates/cluster-template-private.yaml @@ -25,6 +25,8 @@ metadata: name: ${CLUSTER_NAME} namespace: default spec: + bastionSpec: + azureBastion: {} location: ${AZURE_LOCATION} networkSpec: apiServerLB: diff --git a/templates/flavors/azure-bastion/kustomization.yaml b/templates/flavors/azure-bastion/kustomization.yaml new file mode 100644 index 00000000000..f016f77b353 --- /dev/null +++ b/templates/flavors/azure-bastion/kustomization.yaml @@ -0,0 +1,5 @@ +namespace: default +resources: + - ../default +patchesStrategicMerge: + - patches/azure-cluster.yaml diff --git a/templates/flavors/azure-bastion/patches/azure-cluster.yaml b/templates/flavors/azure-bastion/patches/azure-cluster.yaml new file mode 100644 index 00000000000..03a0eda1f8c --- /dev/null +++ b/templates/flavors/azure-bastion/patches/azure-cluster.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 +kind: AzureCluster +metadata: + name: ${CLUSTER_NAME} +spec: + bastionSpec: + azureBastion: {} diff --git a/templates/flavors/private/kustomization.yaml b/templates/flavors/private/kustomization.yaml index 2489830e2ce..34e80105cf0 100644 --- a/templates/flavors/private/kustomization.yaml +++ b/templates/flavors/private/kustomization.yaml @@ -4,4 +4,5 @@ resources: patchesStrategicMerge: - patches/private-lb.yaml - patches/apiserver-host-dns.yaml + - patches/azure-bastion.yaml diff --git a/templates/flavors/private/patches/azure-bastion.yaml b/templates/flavors/private/patches/azure-bastion.yaml new file mode 100644 index 00000000000..03a0eda1f8c --- /dev/null +++ b/templates/flavors/private/patches/azure-bastion.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4 +kind: AzureCluster +metadata: + name: ${CLUSTER_NAME} +spec: + bastionSpec: + azureBastion: {} diff --git a/templates/test/ci/cluster-template-prow-private.yaml b/templates/test/ci/cluster-template-prow-private.yaml index fdc7ce23466..a0d3f621775 100644 --- a/templates/test/ci/cluster-template-prow-private.yaml +++ b/templates/test/ci/cluster-template-prow-private.yaml @@ -29,6 +29,8 @@ spec: buildProvenance: ${BUILD_PROVENANCE} creationTimestamp: ${TIMESTAMP} jobName: ${JOB_NAME} + bastionSpec: + azureBastion: {} location: ${AZURE_LOCATION} networkSpec: apiServerLB: diff --git a/test/e2e/azure_privatecluster.go b/test/e2e/azure_privatecluster.go index 1e92b83ceb9..f72284e13da 100644 --- a/test/e2e/azure_privatecluster.go +++ b/test/e2e/azure_privatecluster.go @@ -29,8 +29,11 @@ import ( "github.com/Azure/go-autorest/autorest/azure/auth" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/utils/pointer" + "sigs.k8s.io/cluster-api-provider-azure/api/v1alpha4" clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4" capi_e2e "sigs.k8s.io/cluster-api/test/e2e" "sigs.k8s.io/cluster-api/test/framework" @@ -119,6 +122,45 @@ func AzurePrivateClusterSpec(ctx context.Context, inputGetter func() AzurePrivat cluster = result.Cluster Expect(cluster).ToNot(BeNil()) + + // Check that azure bastion is provisioned successfully. + { + settings, err := auth.GetSettingsFromEnvironment() + Expect(err).To(BeNil()) + + azureBastionClient := network.NewBastionHostsClient(settings.GetSubscriptionID()) + azureBastionClient.Authorizer, err = settings.GetAuthorizer() + Expect(err).To(BeNil()) + + groupName := os.Getenv(AzureResourceGroup) + azureBastionName := fmt.Sprintf("%s-azure-bastion", clusterName) + + backoff := wait.Backoff{ + Duration: retryBackoffInitialDuration, + Factor: retryBackoffFactor, + Jitter: retryBackoffJitter, + Steps: retryBackoffSteps, + } + retryFn := func() (bool, error) { + bastion, err := azureBastionClient.Get(ctx, groupName, azureBastionName) + if err != nil { + return false, err + } + + switch bastion.ProvisioningState { + case network.Succeeded: + return true, nil + case network.Updating: + // Wait for operation to complete. + return false, nil + default: + return false, errors.New(fmt.Sprintf("Azure Bastion provisioning failed with state: %q", bastion.ProvisioningState)) + } + } + err = wait.ExponentialBackoff(backoff, retryFn) + + Expect(err).To(BeNil()) + } } // SetupExistingVNet creates a resource group and a VNet to be used by a workload cluster. @@ -211,6 +253,14 @@ func SetupExistingVNet(ctx context.Context, vnetCidr string, cpSubnetCidrs, node }) } + // Create the AzureBastion subnet. + subnets = append(subnets, network.Subnet{ + SubnetPropertiesFormat: &network.SubnetPropertiesFormat{ + AddressPrefix: pointer.StringPtr(v1alpha4.DefaultAzureBastionSubnetCIDR), + }, + Name: pointer.StringPtr(v1alpha4.DefaultAzureBastionSubnetName), + }) + vnetFuture, err := vnetClient.CreateOrUpdate(ctx, groupName, os.Getenv(AzureVNetName), network.VirtualNetwork{ Location: pointer.StringPtr(os.Getenv(AzureLocation)), VirtualNetworkPropertiesFormat: &network.VirtualNetworkPropertiesFormat{