diff --git a/api/v1alpha4/tags.go b/api/v1alpha4/tags.go index a7adad92047..32b5769eaf6 100644 --- a/api/v1alpha4/tags.go +++ b/api/v1alpha4/tags.go @@ -136,6 +136,12 @@ const ( // See https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ // for annotation formatting rules. VMTagsLastAppliedAnnotation = "sigs.k8s.io/cluster-api-provider-azure-last-applied-tags-vm" + + // RGTagsLastAppliedAnnotation is the key for the Azure Cluster object annotation + // which tracks the AdditionalTags for Resource Group which is part in the Azure Cluster. + // See https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + // for annotation formatting rules. + RGTagsLastAppliedAnnotation = "sigs.k8s.io/cluster-api-provider-azure-last-applied-tags-rg" ) // SpecVersionHashTagKey is the key for the spec version hash used to enable quick spec difference comparison. diff --git a/azure/defaults.go b/azure/defaults.go index 8643212cd7e..1c595a32cdc 100644 --- a/azure/defaults.go +++ b/azure/defaults.go @@ -172,6 +172,11 @@ func WithIndex(name string, n int) string { return fmt.Sprintf("%s-%d", name, n) } +// ResourceGroupID returns the azure resource ID for a given resource group. +func ResourceGroupID(subscriptionID, resourceGroup string) string { + return fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", subscriptionID, resourceGroup) +} + // VMID returns the azure resource ID for a given VM. func VMID(subscriptionID, resourceGroup, vmName string) string { return fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Compute/virtualMachines/%s", subscriptionID, resourceGroup, vmName) diff --git a/azure/scope/cluster.go b/azure/scope/cluster.go index 1685f26e275..2f7c531cf43 100644 --- a/azure/scope/cluster.go +++ b/azure/scope/cluster.go @@ -18,6 +18,7 @@ package scope import ( "context" + "encoding/json" "fmt" "hash/fnv" "strconv" @@ -747,3 +748,54 @@ func (s *ClusterScope) UpdatePatchStatus(condition clusterv1.ConditionType, serv conditions.MarkFalse(s.AzureCluster, condition, infrav1.FailedReason, clusterv1.ConditionSeverityError, "%s failed to update. err: %s", service, err.Error()) } } + +// AnnotationJSON returns a map[string]interface from a JSON annotation. +func (s *ClusterScope) AnnotationJSON(annotation string) (map[string]interface{}, error) { + out := map[string]interface{}{} + jsonAnnotation := s.AzureCluster.GetAnnotations()[annotation] + if len(jsonAnnotation) == 0 { + return out, nil + } + err := json.Unmarshal([]byte(jsonAnnotation), &out) + if err != nil { + return out, err + } + return out, nil +} + +// UpdateAnnotationJSON updates the `annotation` with +// `content`. `content` in this case should be a `map[string]interface{}` +// suitable for turning into JSON. This `content` map will be marshalled into a +// JSON string before being set as the given `annotation`. +func (s *ClusterScope) UpdateAnnotationJSON(annotation string, content map[string]interface{}) error { + b, err := json.Marshal(content) + if err != nil { + return err + } + s.SetAnnotation(annotation, string(b)) + return nil +} + +// SetAnnotation sets a key value annotation on the AzureCluster. +func (s *ClusterScope) SetAnnotation(key, value string) { + if s.AzureCluster.Annotations == nil { + s.AzureCluster.Annotations = map[string]string{} + } + s.AzureCluster.Annotations[key] = value +} + +func (s *ClusterScope) TagsSpecs() []azure.TagsSpec { + return []azure.TagsSpec{ + { + Scope: azure.ResourceGroupID(s.SubscriptionID(), s.ResourceGroup()), + Tags: infrav1.Build(infrav1.BuildParams{ + ClusterName: s.ClusterName(), + Lifecycle: infrav1.ResourceLifecycleOwned, + Name: to.StringPtr(s.ResourceGroup()), + Role: to.StringPtr(infrav1.CommonRole), + Additional: s.AdditionalTags(), + }), + Annotation: infrav1.RGTagsLastAppliedAnnotation, + }, + } +} diff --git a/azure/scope/managedcontrolplane.go b/azure/scope/managedcontrolplane.go index e0286f7f0ed..84cc913b23b 100644 --- a/azure/scope/managedcontrolplane.go +++ b/azure/scope/managedcontrolplane.go @@ -19,6 +19,7 @@ package scope import ( "context" "encoding/base64" + "encoding/json" "fmt" "net" "strings" @@ -603,3 +604,38 @@ func (s *ManagedControlPlaneScope) UpdatePutStatus(condition clusterv1.Condition func (s *ManagedControlPlaneScope) UpdatePatchStatus(condition clusterv1.ConditionType, service string, err error) { // TODO: add condition to AzureManagedControlPlane status } + +// AnnotationJSON returns a map[string]interface from a JSON annotation. +func (s *ManagedControlPlaneScope) AnnotationJSON(annotation string) (map[string]interface{}, error) { + out := map[string]interface{}{} + jsonAnnotation := s.ControlPlane.GetAnnotations()[annotation] + if len(jsonAnnotation) == 0 { + return out, nil + } + err := json.Unmarshal([]byte(jsonAnnotation), &out) + if err != nil { + return out, err + } + return out, nil +} + +// UpdateAnnotationJSON updates the `annotation` with +// `content`. `content` in this case should be a `map[string]interface{}` +// suitable for turning into JSON. This `content` map will be marshalled into a +// JSON string before being set as the given `annotation`. +func (s *ManagedControlPlaneScope) UpdateAnnotationJSON(annotation string, content map[string]interface{}) error { + b, err := json.Marshal(content) + if err != nil { + return err + } + s.SetAnnotation(annotation, string(b)) + return nil +} + +// SetAnnotation sets a key value annotation on the ControlPlane. +func (s *ManagedControlPlaneScope) SetAnnotation(key, value string) { + if s.ControlPlane.Annotations == nil { + s.ControlPlane.Annotations = map[string]string{} + } + s.ControlPlane.Annotations[key] = value +} diff --git a/azure/services/groups/client.go b/azure/services/groups/client.go index 94e81e66c5c..ee2f7e1e0ea 100644 --- a/azure/services/groups/client.go +++ b/azure/services/groups/client.go @@ -44,7 +44,7 @@ type azureClient struct { var _ client = (*azureClient)(nil) -// newClient creates a new VM client from subscription ID. +// newClient creates a new Resource Group client from subscription ID. func newClient(auth azure.Authorizer) *azureClient { c := newGroupsClient(auth.SubscriptionID(), auth.BaseURI(), auth.Authorizer()) return &azureClient{ diff --git a/azure/services/groups/spec.go b/azure/services/groups/spec.go index b61a3d97639..fa6be878198 100644 --- a/azure/services/groups/spec.go +++ b/azure/services/groups/spec.go @@ -51,6 +51,7 @@ func (s *GroupSpec) OwnerResourceName() string { func (s *GroupSpec) Parameters(existing interface{}) (interface{}, error) { if existing != nil { // rg already exists, nothing to update. + // Note that rg tags are updated separately using tags service return nil, nil } return resources.Group{ diff --git a/azure/services/tags/tags.go b/azure/services/tags/tags.go index c3e8bacadd5..92684a89e6c 100644 --- a/azure/services/tags/tags.go +++ b/azure/services/tags/tags.go @@ -31,7 +31,7 @@ import ( // TagScope defines the scope interface for a tags service. type TagScope interface { logr.Logger - azure.ClusterDescriber + azure.Authorizer TagsSpecs() []azure.TagsSpec AnnotationJSON(string) (map[string]interface{}, error) UpdateAnnotationJSON(string, map[string]interface{}) error @@ -94,7 +94,7 @@ func (s *Service) Reconcile(ctx context.Context) error { return nil } -// Delete is a no-op as the tags get deleted as part of VM deletion. +// Delete is a no-op as the tags get deleted as part of resource deletion. func (s *Service) Delete(ctx context.Context) error { _, span := tele.Tracer().Start(ctx, "tags.Service.Delete") defer span.End() diff --git a/controllers/azurecluster_reconciler.go b/controllers/azurecluster_reconciler.go index 8259cc2f704..21d7425bd5d 100644 --- a/controllers/azurecluster_reconciler.go +++ b/controllers/azurecluster_reconciler.go @@ -19,10 +19,12 @@ package controllers import ( "context" + "github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/resources" "github.com/pkg/errors" clusterv1 "sigs.k8s.io/cluster-api/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/scope" "sigs.k8s.io/cluster-api-provider-azure/azure/services/bastionhosts" "sigs.k8s.io/cluster-api-provider-azure/azure/services/groups" @@ -34,6 +36,7 @@ import ( "sigs.k8s.io/cluster-api-provider-azure/azure/services/routetables" "sigs.k8s.io/cluster-api-provider-azure/azure/services/securitygroups" "sigs.k8s.io/cluster-api-provider-azure/azure/services/subnets" + "sigs.k8s.io/cluster-api-provider-azure/azure/services/tags" "sigs.k8s.io/cluster-api-provider-azure/azure/services/virtualnetworks" "sigs.k8s.io/cluster-api-provider-azure/util/tele" ) @@ -52,6 +55,7 @@ type azureClusterService struct { bastionSvc azure.Reconciler skuCache *resourceskus.Cache natGatewaySvc azure.Reconciler + tagsSvc azure.Reconciler } // newAzureClusterService populates all the services based on input scope. @@ -74,6 +78,7 @@ func newAzureClusterService(scope *scope.ClusterScope) (*azureClusterService, er privateDNSSvc: privatedns.New(scope), bastionSvc: bastionhosts.New(scope), skuCache: skuCache, + tagsSvc: tags.New(scope), }, nil } @@ -131,9 +136,51 @@ func (s *azureClusterService) Reconcile(ctx context.Context) error { return errors.Wrap(err, "failed to reconcile bastion") } + if err := s.reconcileTags(ctx); err != nil { + return errors.Wrap(err, "failed to reconcile resource tags") + } + + return nil +} + +func (s *azureClusterService) reconcileTags(ctx context.Context) error { + // check that the resource group is not BYO. + managed, err := s.isResourceGroupManaged(ctx) + if err != nil { + return errors.Wrap(err, "could not get resource group management state") + } + if !managed { + s.scope.V(2).Info("Should not update resource group tags in unmanaged mode") + return azure.ErrNotOwned + } + + if err := s.tagsSvc.Reconcile(ctx); err != nil { + return errors.Wrap(err, "unable to update tags") + } + return nil } +func (s *azureClusterService) isResourceGroupManaged(ctx context.Context) (bool, error) { + // group service IsGroupManaged method is not available as we use group service as a azure.Reconciler + // which restricts us to only two methods + + // Get resource group client - currently it's a non exported feature in groups package. + // Below is duplicated code without telemetry of azure API calls + groupsClient := resources.NewGroupsClientWithBaseURI(s.scope.BaseURI(), s.scope.SubscriptionID()) + azure.SetAutoRestClientDefaults(&groupsClient.Client, s.scope.Authorizer()) + + // Get resource group + group, err := groupsClient.Get(ctx, s.scope.ResourceGroup()) + if err != nil { + return false, err + } + + // check if resource group is managed or not + tags := converters.MapToTags(group.Tags) + return tags.HasOwned(s.scope.ClusterName()), nil +} + // Delete reconciles all the services in a predetermined order. func (s *azureClusterService) Delete(ctx context.Context) error { ctx, span := tele.Tracer().Start(ctx, "controllers.azureClusterService.Delete")