Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,10 @@ cli/azd/extensions/microsoft.azd.concurx/concurx.exe
cli/azd/extensions/azure.appservice/azureappservice
cli/azd/extensions/azure.appservice/azureappservice.exe
.squad/

# Session artifacts
cli/azd/cover-*
cli/azd/cover_*
review-*.diff

.playwright-mcp/
20 changes: 20 additions & 0 deletions .vscode/cspell.misc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ overrides:
- filename: ./README.md
words:
- VSIX
- filename: ./docs/specs/perf-foundations/**
words:
- Alives
- appsettings
- appservice
- armdeploymentstacks
- azapi
- azsdk
- containerapps
- golangci
- goroutines
- httputil
- keepalives
- nilerr
- nolint
- remotebuild
- resourcegroup
- singleflight
- stdlib
- whatif
- filename: schemas/**/azure.yaml.json
words:
- prodapi
Expand Down
6 changes: 6 additions & 0 deletions cli/azd/.vscode/cspell-azd-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ appinsightsexporter
appinsightsstorage
appplatform
appservice
appsettings
appuser
arget
armapimanagement
Expand Down Expand Up @@ -174,6 +175,7 @@ jmespath
jongio
jquery
keychain
keepalives
kubelogin
langchain
langchaingo
Expand Down Expand Up @@ -204,6 +206,7 @@ mysqlclient
mysqldb
nazd
ndjson
nilerr
nobanner
nodeapp
nolint
Expand Down Expand Up @@ -248,9 +251,11 @@ rabbitmq
reauthentication
relogin
remarshal
remotebuild
repourl
requirepass
resourcegraph
resourcegroup
restoreapp
retriable
runtimes
Expand Down Expand Up @@ -303,6 +308,7 @@ vuejs
webappignore
webfrontend
westus2
whatif
wireinject
yacspin
yamlnode
Expand Down
3 changes: 2 additions & 1 deletion cli/azd/cmd/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ package cmd
import (
"net/http"

"github.com/azure/azure-dev/cli/azd/pkg/httputil"
"github.com/benbjohnson/clock"
)

func createHttpClient() *http.Client {
return http.DefaultClient
return &http.Client{Transport: httputil.TunedTransport()}
}

func createClock() clock.Clock {
Expand Down
62 changes: 51 additions & 11 deletions cli/azd/pkg/azapi/container_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"net/url"
"slices"
"strings"
"sync"
"time"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
Expand All @@ -25,6 +27,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/azure"
"github.com/azure/azure-dev/cli/azd/pkg/httputil"
"github.com/azure/azure-dev/cli/azd/pkg/tools/docker"
"golang.org/x/sync/singleflight"
)

// Credentials for authenticating with a docker registry,
Expand Down Expand Up @@ -61,6 +64,12 @@ type containerRegistryService struct {
docker *docker.Cli
armClientOptions *arm.ClientOptions
coreClientOptions *azcore.ClientOptions
// loginGroup deduplicates concurrent login attempts to the same registry.
// When multiple services share one ACR, only the first goroutine performs
// the credential exchange + docker login; others wait for its result.
loginGroup singleflight.Group
// loginDone tracks registries that have already been authenticated this session.
loginDone sync.Map
}

// Creates a new instance of the ContainerRegistryService
Expand Down Expand Up @@ -118,20 +127,51 @@ func (crs *containerRegistryService) FindContainerRegistryResourceGroup(
}

func (crs *containerRegistryService) Login(ctx context.Context, subscriptionId string, loginServer string) error {
dockerCreds, err := crs.Credentials(ctx, subscriptionId, loginServer)
if err != nil {
return err
cacheKey := subscriptionId + ":" + loginServer
if _, ok := crs.loginDone.Load(cacheKey); ok {
log.Printf("skipping redundant login to %q (already authenticated this session)\n", loginServer)
return nil
}

err = crs.docker.Login(ctx, dockerCreds.LoginServer, dockerCreds.Username, dockerCreds.Password)
if err != nil {
return fmt.Errorf(
"failed logging into docker registry %s: %w",
loginServer,
err)
}
// singleflight deduplicates concurrent login attempts to the same registry.
// We use DoChan so each caller can select on its own ctx.Done(), avoiding the
// problem where one caller's cancellation fails all waiters.
ch := crs.loginGroup.DoChan(cacheKey, func() (any, error) {
// Double-check after winning the singleflight race.
if _, ok := crs.loginDone.Load(cacheKey); ok {
return nil, nil
}

// Use context.WithoutCancel so the shared work isn't tied to a single
// caller's context. Add a bounded timeout so the shared login cannot
// hang indefinitely if Credentials or docker login gets stuck.
const loginTimeout = 5 * time.Minute
opCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginTimeout)
defer cancel()

dockerCreds, err := crs.Credentials(opCtx, subscriptionId, loginServer)
if err != nil {
return nil, err
}

err = crs.docker.Login(opCtx, dockerCreds.LoginServer, dockerCreds.Username, dockerCreds.Password)
if err != nil {
return nil, fmt.Errorf(
"failed logging into docker registry %q: %w",
loginServer,
err)
}

return nil
crs.loginDone.Store(cacheKey, true)
return nil, nil
})

select {
case <-ctx.Done():
return ctx.Err()
case res := <-ch:
return res.Err
}
}

// Credentials gets the credentials that could be used to login to the specified container registry. It prefers to use
Expand Down
24 changes: 22 additions & 2 deletions cli/azd/pkg/azapi/resource_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net/http"
"sync"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
Expand Down Expand Up @@ -61,6 +62,13 @@ type ListResourceGroupResourcesOptions struct {
type ResourceService struct {
credentialProvider account.SubscriptionCredentialProvider
armClientOptions *arm.ClientOptions

// resourcesClients caches armresources.Client instances per subscription ID.
// Azure SDK ARM clients are safe for concurrent use.
resourcesClients sync.Map // map[string]*armresources.Client

// resourceGroupClients caches ResourceGroupsClient instances per subscription ID.
resourceGroupClients sync.Map // map[string]*armresources.ResourceGroupsClient
}

func NewResourceService(
Expand Down Expand Up @@ -320,6 +328,10 @@ func (rs *ResourceService) DeleteResourceGroup(ctx context.Context, subscription
}

func (rs *ResourceService) createResourcesClient(ctx context.Context, subscriptionId string) (*armresources.Client, error) {
if cached, ok := rs.resourcesClients.Load(subscriptionId); ok {
return cached.(*armresources.Client), nil
}

credential, err := rs.credentialProvider.CredentialForSubscription(ctx, subscriptionId)
if err != nil {
return nil, err
Expand All @@ -330,13 +342,19 @@ func (rs *ResourceService) createResourcesClient(ctx context.Context, subscripti
return nil, fmt.Errorf("creating Resource client: %w", err)
}

return client, nil
// Benign race: concurrent miss creates an extra client; LoadOrStore ensures one winner.
actual, _ := rs.resourcesClients.LoadOrStore(subscriptionId, client)
return actual.(*armresources.Client), nil
}

func (rs *ResourceService) createResourceGroupClient(
ctx context.Context,
subscriptionId string,
) (*armresources.ResourceGroupsClient, error) {
if cached, ok := rs.resourceGroupClients.Load(subscriptionId); ok {
return cached.(*armresources.ResourceGroupsClient), nil
}

credential, err := rs.credentialProvider.CredentialForSubscription(ctx, subscriptionId)
if err != nil {
return nil, err
Expand All @@ -347,7 +365,9 @@ func (rs *ResourceService) createResourceGroupClient(
return nil, fmt.Errorf("creating ResourceGroup client: %w", err)
}

return client, nil
// Benign race: concurrent miss creates an extra client; LoadOrStore ensures one winner.
actual, _ := rs.resourceGroupClients.LoadOrStore(subscriptionId, client)
return actual.(*armresources.ResourceGroupsClient), nil
}

// GroupByResourceGroup creates a map of resources group by their resource group name.
Expand Down
Loading
Loading