From 45cafc996d86381ada6ec713ee79dd299e1611cc Mon Sep 17 00:00:00 2001 From: Nader Ziada <nziada@pivotal.io> Date: Wed, 22 Apr 2020 17:29:27 -0400 Subject: [PATCH] add support for azure system assigned identities - add new field for IdentityType in AzureMachineSpec - add new flavor for VMs with system assigned identity - add a role assognment to the system generated identity --- api/v1alpha2/azuremachine_conversion.go | 3 + .../azuremachinetemplate_conversion.go | 3 + api/v1alpha2/zz_generated.conversion.go | 1 + api/v1alpha3/azuremachine_types.go | 7 + api/v1alpha3/types.go | 8 + cloud/services/roleassignments/client.go | 64 ++++++ .../mock_roleassignments/doc.go | 20 ++ .../roleassignments_mock.go | 66 ++++++ cloud/services/virtualmachines/service.go | 17 +- .../virtualmachines/virtualmachines.go | 44 +++- .../virtualmachines/virtualmachines_test.go | 76 ++++++- ...ucture.cluster.x-k8s.io_azureclusters.yaml | 3 + ...ucture.cluster.x-k8s.io_azuremachines.yaml | 10 + ...luster.x-k8s.io_azuremachinetemplates.yaml | 10 + controllers/azuremachine_reconciler.go | 1 + docs/topics/identity.md | 20 ++ ...ter-template-system-assigned-identity.yaml | 197 ++++++++++++++++++ .../kustomization.yaml | 5 + .../patches/system-assigned-identity.yaml | 52 +++++ .../system-assigned-identity-md.yaml | 76 +++++++ 20 files changed, 665 insertions(+), 18 deletions(-) create mode 100644 cloud/services/roleassignments/client.go create mode 100644 cloud/services/roleassignments/mock_roleassignments/doc.go create mode 100644 cloud/services/roleassignments/mock_roleassignments/roleassignments_mock.go create mode 100644 docs/topics/identity.md create mode 100644 templates/cluster-template-system-assigned-identity.yaml create mode 100644 templates/flavors/system-assigned-identity/kustomization.yaml create mode 100644 templates/flavors/system-assigned-identity/patches/system-assigned-identity.yaml create mode 100644 templates/flavors/system-assigned-identity/system-assigned-identity-md.yaml diff --git a/api/v1alpha2/azuremachine_conversion.go b/api/v1alpha2/azuremachine_conversion.go index 8e1feadb5ac2..c8ff3cb13e7a 100644 --- a/api/v1alpha2/azuremachine_conversion.go +++ b/api/v1alpha2/azuremachine_conversion.go @@ -36,6 +36,9 @@ func (src *AzureMachine) ConvertTo(dstRaw conversion.Hub) error { // nolint if ok, err := utilconversion.UnmarshalData(src, restored); err != nil || !ok { return err } + if restored.Spec.Identity != "" { + dst.Spec.Identity = restored.Spec.Identity + } return nil } diff --git a/api/v1alpha2/azuremachinetemplate_conversion.go b/api/v1alpha2/azuremachinetemplate_conversion.go index d1a5da3d3ba4..ffa12222c7ba 100644 --- a/api/v1alpha2/azuremachinetemplate_conversion.go +++ b/api/v1alpha2/azuremachinetemplate_conversion.go @@ -34,6 +34,9 @@ func (src *AzureMachineTemplate) ConvertTo(dstRaw conversion.Hub) error { // nol if ok, err := utilconversion.UnmarshalData(src, restored); err != nil || !ok { return err } + if restored.Spec.Template.Spec.Identity != "" { + dst.Spec.Template.Spec.Identity = restored.Spec.Template.Spec.Identity + } return nil } diff --git a/api/v1alpha2/zz_generated.conversion.go b/api/v1alpha2/zz_generated.conversion.go index ba42f446c33f..b17a4dc06a97 100644 --- a/api/v1alpha2/zz_generated.conversion.go +++ b/api/v1alpha2/zz_generated.conversion.go @@ -634,6 +634,7 @@ func autoConvert_v1alpha3_AzureMachineSpec_To_v1alpha2_AzureMachineSpec(in *v1al } else { out.Image = nil } + // WARNING: in.Identity requires manual conversion: does not exist in peer-type if err := Convert_v1alpha3_OSDisk_To_v1alpha2_OSDisk(&in.OSDisk, &out.OSDisk, s); err != nil { return err } diff --git a/api/v1alpha3/azuremachine_types.go b/api/v1alpha3/azuremachine_types.go index aaa5487de0da..ee016824e32b 100644 --- a/api/v1alpha3/azuremachine_types.go +++ b/api/v1alpha3/azuremachine_types.go @@ -44,6 +44,13 @@ type AzureMachineSpec struct { // +optional Image *Image `json:"image,omitempty"` + // Identity is the type of identity used for the virtual machine. + // The type 'SystemAssigned' is an implicitly created identity + // The generated identity will be assigned a Subscription contributor role + // +kubebuilder:default=None + // +optional + Identity VMIdentity `json:"identity,omitempty"` + OSDisk OSDisk `json:"osDisk"` Location string `json:"location"` diff --git a/api/v1alpha3/types.go b/api/v1alpha3/types.go index 4871cbc66918..2a94ec61fc97 100644 --- a/api/v1alpha3/types.go +++ b/api/v1alpha3/types.go @@ -398,8 +398,16 @@ type AvailabilityZone struct { } // VMIdentity defines the identity of the virtual machine, if configured. +// +kubebuilder:validation:Enum=None;SystemAssigned type VMIdentity string +const ( + // VMIdentityNone ... + VMIdentityNone VMIdentity = "None" + // VMIdentitySystemAssigned ... + VMIdentitySystemAssigned VMIdentity = "SystemAssigned" +) + type OSDisk struct { OSType string `json:"osType"` DiskSizeGB int32 `json:"diskSizeGB"` diff --git a/cloud/services/roleassignments/client.go b/cloud/services/roleassignments/client.go new file mode 100644 index 000000000000..91853aa2154c --- /dev/null +++ b/cloud/services/roleassignments/client.go @@ -0,0 +1,64 @@ +/* +Copyright 2020 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 roleassignments + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/authorization/mgmt/authorization" + "github.com/Azure/go-autorest/autorest" + azure "sigs.k8s.io/cluster-api-provider-azure/cloud" +) + +// Client wraps go-sdk +type Client interface { + Create(context.Context, string, string, authorization.RoleAssignmentCreateParameters) (authorization.RoleAssignment, error) +} + +// AzureClient contains the Azure go-sdk Client +type AzureClient struct { + roleassignments authorization.RoleAssignmentsClient +} + +var _ Client = &AzureClient{} + +// NewClient creates a new role assignment client from subscription ID. +func NewClient(subscriptionID string, authorizer autorest.Authorizer) *AzureClient { + c := newRoleAssignmentClient(subscriptionID, authorizer) + return &AzureClient{c} +} + +// newRoleAssignmentClient creates a role assignments client from subscription ID. +func newRoleAssignmentClient(subscriptionID string, authorizer autorest.Authorizer) authorization.RoleAssignmentsClient { + roleClient := authorization.NewRoleAssignmentsClient(subscriptionID) + roleClient.Authorizer = authorizer + roleClient.AddToUserAgent(azure.UserAgent) + return roleClient +} + +// Create creates a role assignment. +// Parameters: +// scope - the scope of the role assignment to create. The scope can be any REST resource instance. For +// example, use '/subscriptions/{subscription-id}/' for a subscription, +// '/subscriptions/{subscription-id}/resourceGroups/{resource-group-name}' for a resource group, and +// '/subscriptions/{subscription-id}/resourceGroups/{resource-group-name}/providers/{resource-provider}/{resource-type}/{resource-name}' +// for a resource. +// roleAssignmentName - the name of the role assignment to create. It can be any valid GUID. +// parameters - parameters for the role assignment. +func (ac *AzureClient) Create(ctx context.Context, scope string, roleAssignmentName string, parameters authorization.RoleAssignmentCreateParameters) (authorization.RoleAssignment, error) { + return ac.roleassignments.Create(ctx, scope, roleAssignmentName, parameters) +} diff --git a/cloud/services/roleassignments/mock_roleassignments/doc.go b/cloud/services/roleassignments/mock_roleassignments/doc.go new file mode 100644 index 000000000000..27f8c11ebc66 --- /dev/null +++ b/cloud/services/roleassignments/mock_roleassignments/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2020 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. +*/ + +// Run go generate to regenerate this mock. +//go:generate ../../../../hack/tools/bin/mockgen -destination roleassignments_mock.go -package mock_roleassignments -source ../client.go Client +//go:generate /usr/bin/env bash -c "cat ../../../../hack/boilerplate/boilerplate.generatego.txt roleassignments_mock.go > _roleassignments_mock.go && mv _roleassignments_mock.go roleassignments_mock.go" +package mock_roleassignments //nolint diff --git a/cloud/services/roleassignments/mock_roleassignments/roleassignments_mock.go b/cloud/services/roleassignments/mock_roleassignments/roleassignments_mock.go new file mode 100644 index 000000000000..26ddede4cd73 --- /dev/null +++ b/cloud/services/roleassignments/mock_roleassignments/roleassignments_mock.go @@ -0,0 +1,66 @@ +/* +Copyright 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. +*/ + +// Code generated by MockGen. DO NOT EDIT. +// Source: ../client.go + +// Package mock_roleassignments is a generated GoMock package. +package mock_roleassignments + +import ( + context "context" + authorization "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/authorization/mgmt/authorization" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockClient is a mock of Client interface +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// Create mocks base method +func (m *MockClient) Create(arg0 context.Context, arg1, arg2 string, arg3 authorization.RoleAssignmentCreateParameters) (authorization.RoleAssignment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(authorization.RoleAssignment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create +func (mr *MockClientMockRecorder) Create(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockClient)(nil).Create), arg0, arg1, arg2, arg3) +} diff --git a/cloud/services/virtualmachines/service.go b/cloud/services/virtualmachines/service.go index 8eab33eee770..6df117521527 100644 --- a/cloud/services/virtualmachines/service.go +++ b/cloud/services/virtualmachines/service.go @@ -20,6 +20,7 @@ import ( "sigs.k8s.io/cluster-api-provider-azure/cloud/scope" "sigs.k8s.io/cluster-api-provider-azure/cloud/services/networkinterfaces" "sigs.k8s.io/cluster-api-provider-azure/cloud/services/publicips" + "sigs.k8s.io/cluster-api-provider-azure/cloud/services/roleassignments" ) // Service provides operations on azure resources @@ -27,17 +28,19 @@ type Service struct { Scope *scope.ClusterScope MachineScope *scope.MachineScope Client - InterfacesClient networkinterfaces.Client - PublicIPsClient publicips.Client + InterfacesClient networkinterfaces.Client + PublicIPsClient publicips.Client + RoleAssignmentsClient roleassignments.Client } // NewService creates a new service. func NewService(scope *scope.ClusterScope, machineScope *scope.MachineScope) *Service { return &Service{ - Scope: scope, - MachineScope: machineScope, - Client: NewClient(scope.SubscriptionID, scope.Authorizer), - InterfacesClient: networkinterfaces.NewClient(scope.SubscriptionID, scope.Authorizer), - PublicIPsClient: publicips.NewClient(scope.SubscriptionID, scope.Authorizer), + Scope: scope, + MachineScope: machineScope, + Client: NewClient(scope.SubscriptionID, scope.Authorizer), + InterfacesClient: networkinterfaces.NewClient(scope.SubscriptionID, scope.Authorizer), + PublicIPsClient: publicips.NewClient(scope.SubscriptionID, scope.Authorizer), + RoleAssignmentsClient: roleassignments.NewClient(scope.SubscriptionID, scope.Authorizer), } } diff --git a/cloud/services/virtualmachines/virtualmachines.go b/cloud/services/virtualmachines/virtualmachines.go index fbf288790443..feaf83ddf7f3 100644 --- a/cloud/services/virtualmachines/virtualmachines.go +++ b/cloud/services/virtualmachines/virtualmachines.go @@ -23,16 +23,20 @@ import ( "fmt" "strings" + "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/authorization/mgmt/authorization" "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-12-01/compute" "github.com/Azure/go-autorest/autorest/to" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/uuid" "k8s.io/klog" infrav1 "sigs.k8s.io/cluster-api-provider-azure/api/v1alpha3" azure "sigs.k8s.io/cluster-api-provider-azure/cloud" "sigs.k8s.io/cluster-api-provider-azure/cloud/converters" ) +const azureBuiltInContributorID = "b24988ac-6180-42a0-ab88-20f7382dd24c" + // Spec input specification for Get/CreateOrUpdate/Delete calls type Spec struct { Name string @@ -41,6 +45,7 @@ type Spec struct { Size string Zone string Image *infrav1.Image + Identity infrav1.VMIdentity OSDisk infrav1.OSDisk CustomData string } @@ -149,19 +154,56 @@ func (s *Service) Reconcile(ctx context.Context, spec interface{}) error { virtualMachine.Zones = &zones } + if vmSpec.Identity == infrav1.VMIdentitySystemAssigned { + virtualMachine.Identity = &compute.VirtualMachineIdentity{ + Type: compute.ResourceIdentityTypeSystemAssigned, + } + } + err = s.Client.CreateOrUpdate( ctx, s.Scope.ResourceGroup(), vmSpec.Name, virtualMachine) if err != nil { - return errors.Wrapf(err, "cannot create vm") + return errors.Wrapf(err, "cannot create VM") + } + + if vmSpec.Identity == infrav1.VMIdentitySystemAssigned { + err = s.createRoleAssignmentForIdentity(ctx, vmSpec.Name) + if err != nil { + return errors.Wrapf(err, "cannot create VM") + } } klog.V(2).Infof("successfully created VM %s ", vmSpec.Name) return nil } +func (s *Service) createRoleAssignmentForIdentity(ctx context.Context, vmName string) error { + resultVM, err := s.Client.Get(ctx, s.Scope.ResourceGroup(), vmName) + if err != nil { + return errors.Wrapf(err, "cannot get VM to assign role to system assigned identity") + } + + scope := fmt.Sprintf("/subscriptions/%s/", s.Scope.SubscriptionID) + // Azure built-in roles https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles + contributorRoleDefinitionID := fmt.Sprintf("/subscriptions/%s/providers/Microsoft.Authorization/roleDefinitions/%s", s.Scope.SubscriptionID, azureBuiltInContributorID) + params := authorization.RoleAssignmentCreateParameters{ + Properties: &authorization.RoleAssignmentProperties{ + RoleDefinitionID: to.StringPtr(contributorRoleDefinitionID), + PrincipalID: to.StringPtr(*resultVM.Identity.PrincipalID), + }, + } + _, err = s.RoleAssignmentsClient.Create(ctx, scope, string(uuid.NewUUID()), params) + if err != nil { + return errors.Wrapf(err, "cannot assign role to VM system assigned identity") + } + + klog.V(2).Infof("successfully created Role assignment for generated Identity for VM %s ", vmName) + return nil +} + // Delete deletes the virtual machine with the provided name. func (s *Service) Delete(ctx context.Context, spec interface{}) error { vmSpec, ok := spec.(*Spec) diff --git a/cloud/services/virtualmachines/virtualmachines_test.go b/cloud/services/virtualmachines/virtualmachines_test.go index 2fd21aed9439..916850ef8795 100644 --- a/cloud/services/virtualmachines/virtualmachines_test.go +++ b/cloud/services/virtualmachines/virtualmachines_test.go @@ -24,6 +24,7 @@ import ( . "github.com/onsi/gomega" "sigs.k8s.io/cluster-api-provider-azure/cloud/services/networkinterfaces/mock_networkinterfaces" "sigs.k8s.io/cluster-api-provider-azure/cloud/services/publicips/mock_publicips" + "sigs.k8s.io/cluster-api-provider-azure/cloud/services/roleassignments/mock_roleassignments" "sigs.k8s.io/cluster-api-provider-azure/cloud/services/virtualmachines/mock_virtualmachines" "github.com/Azure/go-autorest/autorest" @@ -368,7 +369,7 @@ func TestReconcileVM(t *testing.T) { machine clusterv1.Machine machineConfig *infrav1.AzureMachineSpec azureCluster *infrav1.AzureCluster - expect func(m *mock_virtualmachines.MockClientMockRecorder, mnic *mock_networkinterfaces.MockClientMockRecorder, mpip *mock_publicips.MockClientMockRecorder) + expect func(m *mock_virtualmachines.MockClientMockRecorder, mnic *mock_networkinterfaces.MockClientMockRecorder, mpip *mock_publicips.MockClientMockRecorder, mra *mock_roleassignments.MockClientMockRecorder) expectedError string }{ { @@ -416,12 +417,65 @@ func TestReconcileVM(t *testing.T) { }, }, }, - expect: func(m *mock_virtualmachines.MockClientMockRecorder, mnic *mock_networkinterfaces.MockClientMockRecorder, mpip *mock_publicips.MockClientMockRecorder) { + expect: func(m *mock_virtualmachines.MockClientMockRecorder, mnic *mock_networkinterfaces.MockClientMockRecorder, mpip *mock_publicips.MockClientMockRecorder, mra *mock_roleassignments.MockClientMockRecorder) { mnic.Get(gomock.Any(), gomock.Any(), gomock.Any()) m.CreateOrUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) }, expectedError: "", }, + { + name: "can create a vm with system assigned identity", + machine: clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"set": "node"}, + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + Data: to.StringPtr("bootstrap-data"), + }, + Version: to.StringPtr("1.15.7"), + }, + }, + machineConfig: &infrav1.AzureMachineSpec{ + VMSize: "Standard_B2ms", + Location: "eastus", + Image: image, + Identity: "SystemAssigned", + }, + azureCluster: &infrav1.AzureCluster{ + Spec: infrav1.AzureClusterSpec{ + NetworkSpec: infrav1.NetworkSpec{ + Subnets: infrav1.Subnets{ + &infrav1.SubnetSpec{ + Name: "subnet-1", + }, + &infrav1.SubnetSpec{}, + }, + }, + }, + Status: infrav1.AzureClusterStatus{ + Network: infrav1.Network{ + SecurityGroups: map[infrav1.SecurityGroupRole]infrav1.SecurityGroup{ + infrav1.SecurityGroupControlPlane: { + ID: "1", + }, + infrav1.SecurityGroupNode: { + ID: "2", + }, + }, + APIServerIP: infrav1.PublicIP{ + DNSName: "azure-test-dns", + }, + }, + }, + }, + expect: func(m *mock_virtualmachines.MockClientMockRecorder, mnic *mock_networkinterfaces.MockClientMockRecorder, mpip *mock_publicips.MockClientMockRecorder, mra *mock_roleassignments.MockClientMockRecorder) { + mnic.Get(gomock.Any(), gomock.Any(), gomock.Any()) + m.CreateOrUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) + mra.Create(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) + }, + expectedError: "", + }, { name: "vm creation fails", machine: clusterv1.Machine{ @@ -467,11 +521,11 @@ func TestReconcileVM(t *testing.T) { }, }, }, - expect: func(m *mock_virtualmachines.MockClientMockRecorder, mnic *mock_networkinterfaces.MockClientMockRecorder, mpip *mock_publicips.MockClientMockRecorder) { + expect: func(m *mock_virtualmachines.MockClientMockRecorder, mnic *mock_networkinterfaces.MockClientMockRecorder, mpip *mock_publicips.MockClientMockRecorder, mra *mock_roleassignments.MockClientMockRecorder) { mnic.Get(gomock.Any(), gomock.Any(), gomock.Any()) m.CreateOrUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(autorest.NewErrorWithResponse("", "", &http.Response{StatusCode: 500}, "Internal Server Error")) }, - expectedError: "cannot create vm: #: Internal Server Error: StatusCode=500", + expectedError: "cannot create VM: #: Internal Server Error: StatusCode=500", }, } @@ -481,6 +535,7 @@ func TestReconcileVM(t *testing.T) { vmMock := mock_virtualmachines.NewMockClient(mockCtrl) interfaceMock := mock_networkinterfaces.NewMockClient(mockCtrl) publicIPMock := mock_publicips.NewMockClient(mockCtrl) + roleAssignmentMock := mock_roleassignments.NewMockClient(mockCtrl) cluster := &clusterv1.Cluster{ ObjectMeta: metav1.ObjectMeta{ @@ -528,7 +583,7 @@ func TestReconcileVM(t *testing.T) { g.Expect(err).NotTo(HaveOccurred()) machineScope.AzureMachine.Spec = *tc.machineConfig - tc.expect(vmMock.EXPECT(), interfaceMock.EXPECT(), publicIPMock.EXPECT()) + tc.expect(vmMock.EXPECT(), interfaceMock.EXPECT(), publicIPMock.EXPECT(), roleAssignmentMock.EXPECT()) clusterScope, err := scope.NewClusterScope(scope.ClusterScopeParams{ AzureClients: scope.AzureClients{ @@ -542,11 +597,12 @@ func TestReconcileVM(t *testing.T) { g.Expect(err).NotTo(HaveOccurred()) s := &Service{ - Scope: clusterScope, - MachineScope: machineScope, - Client: vmMock, - InterfacesClient: interfaceMock, - PublicIPsClient: publicIPMock, + Scope: clusterScope, + MachineScope: machineScope, + Client: vmMock, + InterfacesClient: interfaceMock, + PublicIPsClient: publicIPMock, + RoleAssignmentsClient: roleAssignmentMock, } vmSpec := &Spec{ 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 3fb16018bd8c..1ea5cbf7f326 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusters.yaml @@ -614,6 +614,9 @@ spec: identity: description: VMIdentity defines the identity of the virtual machine, if configured. + enum: + - None + - SystemAssigned type: string image: description: Storage profile diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_azuremachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_azuremachines.yaml index 915d307f5007..152f94266cec 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_azuremachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_azuremachines.yaml @@ -253,6 +253,16 @@ spec: id: type: string type: object + identity: + default: None + description: Identity is the type of identity used for the virtual + machine. The type 'SystemAssigned' is an implicitly created identity + The generated identity will be assigned a Subscription contributor + role + enum: + - None + - SystemAssigned + type: string image: description: Image is used to provide details of an image to use during VM creation. If image details are omitted the image will default diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_azuremachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_azuremachinetemplates.yaml index 98662904be1e..5cc0033d0046 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_azuremachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_azuremachinetemplates.yaml @@ -190,6 +190,16 @@ spec: id: type: string type: object + identity: + default: None + description: Identity is the type of identity used for the + virtual machine. The type 'SystemAssigned' is an implicitly + created identity The generated identity will be assigned + a Subscription contributor role + enum: + - None + - SystemAssigned + type: string image: description: Image is used to provide details of an image to use during VM creation. If image details are omitted diff --git a/controllers/azuremachine_reconciler.go b/controllers/azuremachine_reconciler.go index 9af34c7e61c7..0f47f23d8103 100644 --- a/controllers/azuremachine_reconciler.go +++ b/controllers/azuremachine_reconciler.go @@ -305,6 +305,7 @@ func (s *azureMachineService) createVirtualMachine(nicName string) (*infrav1.VM, Image: image, CustomData: bootstrapData, Zone: vmZone, + Identity: s.machineScope.AzureMachine.Spec.Identity, } err = s.virtualMachinesSvc.Reconcile(s.clusterScope.Context, vmSpec) diff --git a/docs/topics/identity.md b/docs/topics/identity.md new file mode 100644 index 000000000000..a0e1e8093727 --- /dev/null +++ b/docs/topics/identity.md @@ -0,0 +1,20 @@ +# Identity + +Managed identities for Azure resources is a feature of Azure Active Directory. Each of the [Azure services that support managed identities for Azure resources](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/services-support-msi) are subject to their own timeline. Make sure you review the [availability](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/services-support-msi) status of managed identities for your resource and [known issues](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/known-issues) before you begin. + +This feature is used to create nodes which have an identity provisioned onto the node by the Azure control plane, rather than providing credentials in the azure.json file. This is a preferred way to manage identities and roles for a given resource in Azure as the lifespan of the identity is linked to the lifespan of the resource. + +### Flavors of Identities in Azure +All identities used in Azure are owned by Azure Active Directory (AAD). An identity, or principal, in AAD will provide the basis for each of the flavors of identities we will describe. + +### Service Principal +A service principal is an identity in AAD which is described by a TenantID, ClientID, and ClientSecret. The set of these three values will enable the holder to exchange the values for a JWT token to communicate with Azure. The values are normally stored in a file or environment variables. The user generally creates a service principal, saves the credentials, and then uses the credentials in applications. + + +### System Assigned Identity +A system assigned identity is a managed identity which is tied to the lifespan of a resource in Azure. The identity is created by Azure in AAD for the resource it is applied upon and reaped when the resource is deleted. Unlike a service principal, a system assigned identity is available on the local resource through a local port service via the instance metadata service. + +To use the System assigned identity, you should use the template for the `system-assigned-identity` flavor, `{flavor}` is the name the user can pass to the `clusterctl config cluster --flavor` flag to identify the specific template to use. + +⚠️ **When a Node is created with a System Assigned Identity, A role of Subscription contributor is added to this generated Identity** + diff --git a/templates/cluster-template-system-assigned-identity.yaml b/templates/cluster-template-system-assigned-identity.yaml new file mode 100644 index 000000000000..c7e3935a2eb6 --- /dev/null +++ b/templates/cluster-template-system-assigned-identity.yaml @@ -0,0 +1,197 @@ +apiVersion: bootstrap.cluster.x-k8s.io/v1alpha3 +kind: KubeadmConfigTemplate +metadata: + name: ${CLUSTER_NAME}-md-0 +spec: + template: + spec: + files: + - content: | + { + "cloud": "AzurePublicCloud", + "tenantId": "${AZURE_TENANT_ID}", + "subscriptionId": "${AZURE_SUBSCRIPTION_ID}", + "resourceGroup": "${CLUSTER_NAME}", + "securityGroupName": "${CLUSTER_NAME}-node-nsg", + "location": "${AZURE_LOCATION}", + "vmType": "standard", + "vnetName": "${CLUSTER_NAME}-vnet", + "vnetResourceGroup": "${CLUSTER_NAME}", + "subnetName": "${CLUSTER_NAME}-node-subnet", + "routeTableName": "${CLUSTER_NAME}-node-routetable", + "loadBalancerSku": "standard", + "maximumLoadBalancerRuleCount": 250, + "useManagedIdentityExtension": true, + "useInstanceMetadata": true + } + owner: root:root + path: /etc/kubernetes/azure.json + permissions: "0644" + joinConfiguration: + nodeRegistration: + kubeletExtraArgs: + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + name: '{{ ds.meta_data["local_hostname"] }}' +--- +apiVersion: cluster.x-k8s.io/v1alpha3 +kind: Cluster +metadata: + name: ${CLUSTER_NAME} + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 + controlPlaneRef: + apiVersion: controlplane.cluster.x-k8s.io/v1alpha3 + kind: KubeadmControlPlane + name: ${CLUSTER_NAME}-control-plane + infrastructureRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 + kind: AzureCluster + name: ${CLUSTER_NAME} +--- +apiVersion: cluster.x-k8s.io/v1alpha3 +kind: MachineDeployment +metadata: + name: ${CLUSTER_NAME}-md-0 +spec: + clusterName: ${CLUSTER_NAME} + replicas: ${WORKER_MACHINE_COUNT} + selector: + matchLabels: null + template: + spec: + bootstrap: + configRef: + apiVersion: bootstrap.cluster.x-k8s.io/v1alpha3 + kind: KubeadmConfigTemplate + name: ${CLUSTER_NAME}-md-0 + clusterName: ${CLUSTER_NAME} + infrastructureRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 + kind: AzureMachineTemplate + name: ${CLUSTER_NAME}-md-0 + version: ${KUBERNETES_VERSION} +--- +apiVersion: controlplane.cluster.x-k8s.io/v1alpha3 +kind: KubeadmControlPlane +metadata: + name: ${CLUSTER_NAME}-control-plane + namespace: default +spec: + infrastructureTemplate: + apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 + kind: AzureMachineTemplate + name: ${CLUSTER_NAME}-control-plane + 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 + extraVolumes: + - hostPath: /etc/kubernetes/azure.json + mountPath: /etc/kubernetes/azure.json + name: cloud-config + readOnly: true + files: + - content: | + { + "cloud": "AzurePublicCloud", + "tenantId": "${AZURE_TENANT_ID}", + "subscriptionId": "${AZURE_SUBSCRIPTION_ID}", + "aadClientId": "${AZURE_CLIENT_ID}", + "aadClientSecret": "${AZURE_CLIENT_SECRET}", + "resourceGroup": "${AZURE_RESOURCE_GROUP}", + "securityGroupName": "${CLUSTER_NAME}-node-nsg", + "location": "${AZURE_LOCATION}", + "vmType": "standard", + "vnetName": "${CLUSTER_NAME}-vnet", + "vnetResourceGroup": "${CLUSTER_NAME}", + "subnetName": "${CLUSTER_NAME}-node-subnet", + "routeTableName": "${CLUSTER_NAME}-node-routetable", + "userAssignedID": "${CLUSTER_NAME}", + "loadBalancerSku": "standard", + "maximumLoadBalancerRuleCount": 250, + "useManagedIdentityExtension": false, + "useInstanceMetadata": true + } + owner: root:root + path: /etc/kubernetes/azure.json + permissions: "0644" + initConfiguration: + nodeRegistration: + kubeletExtraArgs: + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + name: '{{ ds.meta_data["local_hostname"] }}' + joinConfiguration: + nodeRegistration: + kubeletExtraArgs: + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + name: '{{ ds.meta_data["local_hostname"] }}' + useExperimentalRetryJoin: true + replicas: ${CONTROL_PLANE_MACHINE_COUNT} + version: ${KUBERNETES_VERSION} +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 +kind: AzureCluster +metadata: + name: ${CLUSTER_NAME} + namespace: default +spec: + location: ${AZURE_LOCATION} + networkSpec: + vnet: + name: ${AZURE_VNET_NAME} + resourceGroup: ${AZURE_RESOURCE_GROUP} +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 +kind: AzureMachineTemplate +metadata: + name: ${CLUSTER_NAME}-control-plane + namespace: default +spec: + template: + spec: + identity: SystemAssigned + location: ${AZURE_LOCATION} + osDisk: + diskSizeGB: 128 + managedDisk: + storageAccountType: Premium_LRS + osType: Linux + sshPublicKey: ${AZURE_SSH_PUBLIC_KEY} + vmSize: ${AZURE_CONTROL_PLANE_MACHINE_TYPE} +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 +kind: AzureMachineTemplate +metadata: + name: ${CLUSTER_NAME}-md-0 +spec: + template: + spec: + identity: SystemAssigned + location: ${AZURE_LOCATION} + osDisk: + diskSizeGB: 30 + managedDisk: + storageAccountType: Premium_LRS + osType: Linux + sshPublicKey: ${AZURE_SSH_PUBLIC_KEY} + vmSize: ${AZURE_NODE_MACHINE_TYPE} diff --git a/templates/flavors/system-assigned-identity/kustomization.yaml b/templates/flavors/system-assigned-identity/kustomization.yaml new file mode 100644 index 000000000000..f6e0f53d7b50 --- /dev/null +++ b/templates/flavors/system-assigned-identity/kustomization.yaml @@ -0,0 +1,5 @@ +resources: + - ../base + - system-assigned-identity-md.yaml +patchesStrategicMerge: + - patches/system-assigned-identity.yaml \ No newline at end of file diff --git a/templates/flavors/system-assigned-identity/patches/system-assigned-identity.yaml b/templates/flavors/system-assigned-identity/patches/system-assigned-identity.yaml new file mode 100644 index 000000000000..76b8b5afca4e --- /dev/null +++ b/templates/flavors/system-assigned-identity/patches/system-assigned-identity.yaml @@ -0,0 +1,52 @@ +apiVersion: bootstrap.cluster.x-k8s.io/v1alpha3 +kind: KubeadmConfigTemplate +metadata: + name: ${CLUSTER_NAME}-md-0 +spec: + template: + spec: + files: + - content: | + { + "cloud": "AzurePublicCloud", + "tenantId": "${AZURE_TENANT_ID}", + "subscriptionId": "${AZURE_SUBSCRIPTION_ID}", + "resourceGroup": "${CLUSTER_NAME}", + "securityGroupName": "${CLUSTER_NAME}-node-nsg", + "location": "${AZURE_LOCATION}", + "vmType": "standard", + "vnetName": "${CLUSTER_NAME}-vnet", + "vnetResourceGroup": "${CLUSTER_NAME}", + "subnetName": "${CLUSTER_NAME}-node-subnet", + "routeTableName": "${CLUSTER_NAME}-node-routetable", + "loadBalancerSku": "standard", + "maximumLoadBalancerRuleCount": 250, + "useManagedIdentityExtension": true, + "useInstanceMetadata": true + } + owner: root:root + path: /etc/kubernetes/azure.json + permissions: "0644" + joinConfiguration: + nodeRegistration: + kubeletExtraArgs: + cloud-config: /etc/kubernetes/azure.json + cloud-provider: azure + name: '{{ ds.meta_data["local_hostname"] }}' +--- +kind: AzureMachineTemplate +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 +metadata: + name: "${CLUSTER_NAME}-control-plane" +spec: + template: + spec: + location: ${AZURE_LOCATION} + identity: SystemAssigned + vmSize: ${AZURE_CONTROL_PLANE_MACHINE_TYPE} + osDisk: + osType: "Linux" + diskSizeGB: 128 + managedDisk: + storageAccountType: "Premium_LRS" + sshPublicKey: ${AZURE_SSH_PUBLIC_KEY} diff --git a/templates/flavors/system-assigned-identity/system-assigned-identity-md.yaml b/templates/flavors/system-assigned-identity/system-assigned-identity-md.yaml new file mode 100644 index 000000000000..2e12e81cf928 --- /dev/null +++ b/templates/flavors/system-assigned-identity/system-assigned-identity-md.yaml @@ -0,0 +1,76 @@ +--- +apiVersion: cluster.x-k8s.io/v1alpha3 +kind: MachineDeployment +metadata: + name: "${CLUSTER_NAME}-md-0" +spec: + clusterName: "${CLUSTER_NAME}" + replicas: ${WORKER_MACHINE_COUNT} + selector: + matchLabels: + template: + spec: + clusterName: "${CLUSTER_NAME}" + version: "${KUBERNETES_VERSION}" + bootstrap: + configRef: + name: "${CLUSTER_NAME}-md-0" + apiVersion: bootstrap.cluster.x-k8s.io/v1alpha3 + kind: KubeadmConfigTemplate + infrastructureRef: + name: "${CLUSTER_NAME}-md-0" + apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 + kind: AzureMachineTemplate +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha3 +kind: AzureMachineTemplate +metadata: + name: "${CLUSTER_NAME}-md-0" +spec: + template: + spec: + location: ${AZURE_LOCATION} + identity: SystemAssigned + vmSize: ${AZURE_NODE_MACHINE_TYPE} + osDisk: + osType: "Linux" + diskSizeGB: 30 + managedDisk: + storageAccountType: "Premium_LRS" + sshPublicKey: ${AZURE_SSH_PUBLIC_KEY} +--- +apiVersion: bootstrap.cluster.x-k8s.io/v1alpha3 +kind: KubeadmConfigTemplate +metadata: + name: "${CLUSTER_NAME}-md-0" +spec: + template: + spec: + joinConfiguration: + nodeRegistration: + name: '{{ ds.meta_data["local_hostname"] }}' + kubeletExtraArgs: + cloud-provider: azure + cloud-config: /etc/kubernetes/azure.json + files: + - path: /etc/kubernetes/azure.json + owner: "root:root" + permissions: "0644" + content: | + { + "cloud": "AzurePublicCloud", + "tenantId": "${AZURE_TENANT_ID}", + "subscriptionId": "${AZURE_SUBSCRIPTION_ID}", + "resourceGroup": "${CLUSTER_NAME}", + "securityGroupName": "${CLUSTER_NAME}-node-nsg", + "location": "${AZURE_LOCATION}", + "vmType": "standard", + "vnetName": "${CLUSTER_NAME}-vnet", + "vnetResourceGroup": "${CLUSTER_NAME}", + "subnetName": "${CLUSTER_NAME}-node-subnet", + "routeTableName": "${CLUSTER_NAME}-node-routetable", + "loadBalancerSku": "standard", + "maximumLoadBalancerRuleCount": 250, + "useManagedIdentityExtension": true, + "useInstanceMetadata": true + }