diff --git a/backend/pkg/app/backend.go b/backend/pkg/app/backend.go index a19e39bc4ae..cfd32c1bd06 100644 --- a/backend/pkg/app/backend.go +++ b/backend/pkg/app/backend.go @@ -484,6 +484,13 @@ func (b *Backend) runBackendControllersUnderLeaderElection(ctx context.Context, backendInformers, ) + fetchDataPlaneOperatorsManagedIdentitiesInfoController := controllers.NewFetchDataPlaneOperatorsManagedIdentitiesInfoController( + b.options.CosmosDBClient, + activeOperationLister, + backendInformers, + b.options.SMIClientBuilder, + ) + le, err := leaderelection.NewLeaderElector(leaderelection.LeaderElectionConfig{ Lock: b.options.LeaderElectionLock, LeaseDuration: leaderElectionLeaseDuration, @@ -527,6 +534,7 @@ func (b *Backend) runBackendControllersUnderLeaderElection(ctx context.Context, go maestroReadAndPersistReadonlyBundlesContentController.Run(ctx, 20) go maestroDeleteOrphanedReadonlyBundlesController.Run(ctx, 20) go triggerNodePoolUpgradeController.Run(ctx, 20) + go fetchDataPlaneOperatorsManagedIdentitiesInfoController.Run(ctx, 20) }, OnStoppedLeading: func() { // This needs to be defined even though it does nothing. diff --git a/backend/pkg/controllers/fetch_data_plane_operators_managed_identities_info.go b/backend/pkg/controllers/fetch_data_plane_operators_managed_identities_info.go new file mode 100644 index 00000000000..c3e2606190f --- /dev/null +++ b/backend/pkg/controllers/fetch_data_plane_operators_managed_identities_info.go @@ -0,0 +1,189 @@ +// Copyright 2026 Microsoft Corporation +// +// 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 controllers + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "k8s.io/apimachinery/pkg/api/equality" + + azcorearm "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + + azureclient "github.com/Azure/ARO-HCP/backend/pkg/azure/client" + "github.com/Azure/ARO-HCP/backend/pkg/controllers/controllerutils" + "github.com/Azure/ARO-HCP/backend/pkg/informers" + "github.com/Azure/ARO-HCP/backend/pkg/listers" + "github.com/Azure/ARO-HCP/internal/api" + "github.com/Azure/ARO-HCP/internal/database" + "github.com/Azure/ARO-HCP/internal/utils" +) + +// fetchDataPlaneOperatorsManagedIdentitiesInfoSyncer is a controller that +// fetches the Client ID and Principal ID of the data plane operators managed identities +// associated to the cluster and stores them in Cosmos. +type fetchDataPlaneOperatorsManagedIdentitiesInfoSyncer struct { + cooldownChecker controllerutils.CooldownChecker + + cosmosClient database.DBClient + + smiClientBuilder azureclient.ServiceManagedIdentityClientBuilder +} + +var _ controllerutils.ClusterSyncer = (*fetchDataPlaneOperatorsManagedIdentitiesInfoSyncer)(nil) + +func NewFetchDataPlaneOperatorsManagedIdentitiesInfoController( + cosmosClient database.DBClient, + activeOperationLister listers.ActiveOperationLister, + backendInformers informers.BackendInformers, + smiClientBuilder azureclient.ServiceManagedIdentityClientBuilder, +) controllerutils.Controller { + + syncer := &fetchDataPlaneOperatorsManagedIdentitiesInfoSyncer{ + cooldownChecker: controllerutils.DefaultActiveOperationPrioritizingCooldown(activeOperationLister), + cosmosClient: cosmosClient, + smiClientBuilder: smiClientBuilder, + } + + controller := controllerutils.NewClusterWatchingController( + "FetchDataPlaneOperatorsManagedIdentitiesInfo", + cosmosClient, + backendInformers, + 1*time.Minute, + syncer, + ) + + return controller +} + +func (c *fetchDataPlaneOperatorsManagedIdentitiesInfoSyncer) SyncOnce(ctx context.Context, key controllerutils.HCPClusterKey) error { + existingCluster, err := c.cosmosClient.HCPClusters(key.SubscriptionID, key.ResourceGroupName).Get(ctx, key.HCPClusterName) + if database.IsResponseError(err, http.StatusNotFound) { + return nil // cluster doesn't exist, no work to do + } + if err != nil { + return utils.TrackError(fmt.Errorf("failed to get Cluster: %w", err)) + } + + // TODO unclear if we should put the data plane operators managed identities info in the ServiceProviderCluster resource or in + // the HCPCluster resource. For now we put it in the ServiceProviderCluster resource. + existingServiceProviderCluster, err := controllerutils.GetOrCreateServiceProviderCluster(ctx, c.cosmosClient, key.GetResourceID()) + if err != nil { + return utils.TrackError(fmt.Errorf("failed to get or create ServiceProviderCluster: %w", err)) + } + + type identityToSync struct { + ResourceID *azcorearm.ResourceID + OperatorName string + } + + identitiesToSync := []*identityToSync{} + for operatorName, dataPlaneOperatorResourceID := range existingCluster.CustomerProperties.Platform.OperatorsAuthentication.UserAssignedIdentities.DataPlaneOperators { + currentMI, ok := existingServiceProviderCluster.Status.DataPlaneOperatorsManagedIdentities[dataPlaneOperatorResourceID.String()] + if !ok { + identitiesToSync = append(identitiesToSync, &identityToSync{ + ResourceID: dataPlaneOperatorResourceID, + OperatorName: operatorName, + }) + continue + } + + if len(currentMI.ClientID) == 0 || len(currentMI.PrincipalID) == 0 || len(currentMI.OperatorName) == 0 { + identitiesToSync = append(identitiesToSync, &identityToSync{ + ResourceID: dataPlaneOperatorResourceID, + OperatorName: operatorName, + }) + continue + } + + } + if len(identitiesToSync) == 0 { + return nil + } + + smiResourceID := existingCluster.CustomerProperties.Platform.OperatorsAuthentication.UserAssignedIdentities.ServiceManagedIdentity + uaisClient, err := c.smiClientBuilder.UserAssignedIdentitiesClient(ctx, existingCluster.ServiceProviderProperties.ManagedIdentitiesDataPlaneIdentityURL, smiResourceID, existingCluster.ID.SubscriptionID) + if err != nil { + return utils.TrackError(fmt.Errorf("failed to get User Assigned Identities Client: %w", err)) + } + + desiredDataPlaneOperatorsManagedIdentities := make(map[string]*api.ServiceProviderClusterDataPlaneOperatorManagedIdentity) + + var syncErrors []error + for _, dataPlaneOperatorIdentityToSync := range identitiesToSync { + desiredDataPlaneOperatorsManagedIdentities[dataPlaneOperatorIdentityToSync.ResourceID.String()] = &api.ServiceProviderClusterDataPlaneOperatorManagedIdentity{ + ResourceID: dataPlaneOperatorIdentityToSync.ResourceID, + OperatorName: dataPlaneOperatorIdentityToSync.OperatorName, + } + currentMI, err := uaisClient.Get(ctx, dataPlaneOperatorIdentityToSync.ResourceID.ResourceGroupName, dataPlaneOperatorIdentityToSync.ResourceID.Name, nil) + if err != nil { + syncErrors = append(syncErrors, utils.TrackError(fmt.Errorf("failed to get Data Plane Operator Managed Identity: %w", err))) + continue + } + + if currentMI.Properties == nil { + syncErrors = append(syncErrors, utils.TrackError(fmt.Errorf("unexpected Data Plane Operator Managed Identity %s Properties is nil", dataPlaneOperatorIdentityToSync.ResourceID.String()))) + continue + } + + if currentMI.Properties.ClientID != nil && len(*currentMI.Properties.ClientID) > 0 { + desiredDataPlaneOperatorsManagedIdentities[dataPlaneOperatorIdentityToSync.ResourceID.String()].ClientID = *currentMI.Properties.ClientID + } else { + syncErrors = append(syncErrors, utils.TrackError(fmt.Errorf("unexpected Data Plane Operator Managed Identity %s Client ID is nil or empty", dataPlaneOperatorIdentityToSync.ResourceID.String()))) + } + + if currentMI.Properties.PrincipalID != nil && len(*currentMI.Properties.PrincipalID) > 0 { + desiredDataPlaneOperatorsManagedIdentities[dataPlaneOperatorIdentityToSync.ResourceID.String()].PrincipalID = *currentMI.Properties.PrincipalID + } else { + syncErrors = append(syncErrors, utils.TrackError(fmt.Errorf("unexpected Data Plane Operator Managed Identity %s Principal ID is nil or empty", dataPlaneOperatorIdentityToSync.ResourceID.String()))) + } + } + + if !equality.Semantic.DeepEqual(existingServiceProviderCluster.Status.DataPlaneOperatorsManagedIdentities, desiredDataPlaneOperatorsManagedIdentities) { + if existingServiceProviderCluster.Status.DataPlaneOperatorsManagedIdentities == nil { + existingServiceProviderCluster.Status.DataPlaneOperatorsManagedIdentities = make(map[string]*api.ServiceProviderClusterDataPlaneOperatorManagedIdentity) + } + + for _, desired := range desiredDataPlaneOperatorsManagedIdentities { + key := desired.ResourceID.String() + entry := existingServiceProviderCluster.Status.DataPlaneOperatorsManagedIdentities[key] + if entry == nil { + entry = &api.ServiceProviderClusterDataPlaneOperatorManagedIdentity{} + existingServiceProviderCluster.Status.DataPlaneOperatorsManagedIdentities[key] = entry + } + entry.ResourceID = desired.ResourceID + entry.OperatorName = desired.OperatorName + if len(desired.ClientID) > 0 { + entry.ClientID = desired.ClientID + } + if len(desired.PrincipalID) > 0 { + entry.PrincipalID = desired.PrincipalID + } + } + + _, err := c.cosmosClient.ServiceProviderClusters(existingCluster.ID.SubscriptionID, existingCluster.ID.ResourceGroupName, existingCluster.ID.Name).Replace(ctx, existingServiceProviderCluster, nil) + if err != nil { + syncErrors = append(syncErrors, utils.TrackError(fmt.Errorf("failed to replace ServiceProviderCluster: %w", err))) + } + } + + return errors.Join(syncErrors...) +} + +func (c *fetchDataPlaneOperatorsManagedIdentitiesInfoSyncer) CooldownChecker() controllerutils.CooldownChecker { + return c.cooldownChecker +} diff --git a/internal/api/types_serviceprovider_cluster.go b/internal/api/types_serviceprovider_cluster.go index abe7d535e98..42b799bc82f 100644 --- a/internal/api/types_serviceprovider_cluster.go +++ b/internal/api/types_serviceprovider_cluster.go @@ -111,6 +111,19 @@ type ServiceProviderClusterStatus struct { // The reference contains a mapping between the logical name we give to the Maestro bundle internally // and the Maestro Bundle Name and ID at the Maestro API level. MaestroReadonlyBundles MaestroBundleReferenceList `json:"maestroReadonlyBundles,omitempty"` + + // DataPlaneOperatorsManagedIdentities is a map of data plane operator managed identities. + // The key is the Azure Resource ID of the managed identity + // TODO do we want the key to be the operator name or the Azure Resource ID? + // TODO do we want to store both the operator name and the Azure Resource ID? + DataPlaneOperatorsManagedIdentities map[string]*ServiceProviderClusterDataPlaneOperatorManagedIdentity `json:"dataPlaneOperatorsManagedIdentities,omitempty"` +} + +type ServiceProviderClusterDataPlaneOperatorManagedIdentity struct { + OperatorName string `json:"operatorName"` + ResourceID *azcorearm.ResourceID `json:"resourceID"` + ClientID string `json:"clientID"` + PrincipalID string `json:"principalID"` } // ServiceProviderClusterStatusVersion contains the actual version information. diff --git a/test-integration/go.mod b/test-integration/go.mod index e08ca522efa..b46c9f2c218 100644 --- a/test-integration/go.mod +++ b/test-integration/go.mod @@ -27,10 +27,17 @@ require ( require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/azure-kusto-go v0.16.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v0.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect github.com/Azure/azure-sdk-for-go/sdk/tracing/azotel v0.4.0 // indirect + github.com/Azure/msi-dataplane v0.4.3 // indirect github.com/Azure/retry v0.0.0-20250221010952-92c9290cea0f // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bwmarrin/snowflake v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -40,6 +47,7 @@ require ( github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/getsentry/sentry-go v0.20.0 // indirect github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 // indirect diff --git a/test-integration/go.sum b/test-integration/go.sum index 5a9d5fe8b9e..a9c83ec2260 100644 --- a/test-integration/go.sum +++ b/test-integration/go.sum @@ -16,8 +16,14 @@ github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v1.4.1 h1:ToPLhnXvatKVN4Zkcx github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v1.4.1/go.mod h1:Krtog/7tz27z75TwM5cIS8bxEH4dcBUezcq+kGVeZEo= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.1 h1:1kpY4qe+BGAH2ykv4baVSqyx+AY5VjXeJ15SldlU6hs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.1/go.mod h1:nT6cWpWdUt+g81yuKmjeYPUtI73Ak3yQIT4PVVsCEEQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.2.0 h1:akP6VpxJGgQRpDR1P462piz/8OhYLRCreDj48AyNabc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.2.0/go.mod h1:8wzvopPfyZYPaQUoKW87Zfdul7jmJMDfp/k7YY3oJyA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 h1:L7G3dExHBgUxsO3qpTGhk/P2dgnYyW48yn7AO33Tbek= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0/go.mod h1:Ms6gYEy0+A2knfKrwdatsggTXYA2+ICKug8w7STorFw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 h1:HYGD75g0bQ3VO/Omedm54v4LrD3B1cGImuRF3AJ5wLo= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0/go.mod h1:ulHyBFJOI0ONiRL4vcJTmS7rx18jQQlEPmAgo80cRdM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v0.2.0 h1:bYq3jfB2x36hslKMHyge3+esWzROtJNk/4dCjsKlrl4= @@ -26,8 +32,18 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1. github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1/go.mod h1:8h8yhzh9o+0HeSIhUxYny+rEQajScrfIpNktvgYG3Q8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= github.com/Azure/azure-sdk-for-go/sdk/tracing/azotel v0.4.0 h1:RTTsXUJWn0jumeX62Mb153wYXykqnrzYBYDeHp0kiuk= github.com/Azure/azure-sdk-for-go/sdk/tracing/azotel v0.4.0/go.mod h1:k4MMjrPHIEK+umaMGk1GNLgjEybJZ9mHSRDZ+sDFv3Y= +github.com/Azure/msi-dataplane v0.4.3 h1:dWPWzY4b54tLIR9T1Q014Xxd/1DxOsMIp6EjRFAJlQY= +github.com/Azure/msi-dataplane v0.4.3/go.mod h1:yAfxdJyvcnvSDfSyOFV9qm4fReEQDl+nZLGeH2ZWSmw= github.com/Azure/retry v0.0.0-20250221010952-92c9290cea0f h1:XjKfallhRhddiRmBG0u2gs+Rd75QjvAlPVDy3ZWLjPg= github.com/Azure/retry v0.0.0-20250221010952-92c9290cea0f/go.mod h1:4FpEaBWwrdI8kVPeNESpqzIYAZipu7K6MCGCCC6bJ/A= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= @@ -36,6 +52,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -66,6 +84,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getsentry/sentry-go v0.20.0 h1:bwXW98iMRIWxn+4FgPW7vMrjmbym6HblXALmhjHmQaQ=