Skip to content
Merged
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 @@ -47,6 +47,7 @@ appinsightsexporter
appinsightsstorage
appplatform
appservice
appsettings
appuser
arget
armapimanagement
Expand Down Expand Up @@ -175,6 +176,7 @@ jmespath
jongio
jquery
keychain
keepalives
kubelogin
langchain
langchaingo
Expand Down Expand Up @@ -205,6 +207,7 @@ mysqlclient
mysqldb
nazd
ndjson
nilerr
nobanner
nodeapp
nolint
Expand Down Expand Up @@ -249,9 +252,11 @@ rabbitmq
reauthentication
relogin
remarshal
remotebuild
repourl
requirepass
resourcegraph
resourcegroup
restoreapp
retriable
runtimes
Expand Down Expand Up @@ -304,6 +309,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
24 changes: 24 additions & 0 deletions cli/azd/internal/tracing/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,27 @@ const (
// skips Kubernetes context setup because the cluster isn't available yet.
AksPostprovisionSkipEvent = "aks.postprovision.skip"
)

// ARM deployment events track provisioning, validation, and preview operations.
const (
ArmDeploySubscriptionEvent = "arm.deploy.subscription"
ArmDeployResourceGroupEvent = "arm.deploy.resourcegroup"
ArmStackDeploySubscriptionEvent = "arm.stack.deploy.subscription"
ArmStackDeployResourceGroupEvent = "arm.stack.deploy.resourcegroup"
ArmWhatIfSubscriptionEvent = "arm.whatif.subscription"
ArmWhatIfResourceGroupEvent = "arm.whatif.resourcegroup"
ArmValidateSubscriptionEvent = "arm.validate.subscription"
ArmValidateResourceGroupEvent = "arm.validate.resourcegroup"
)

// App Service deployment events.
const (
DeployAppServiceZipEvent = "deploy.appservice.zip"
)

// Container lifecycle events.
const (
ContainerCredentialsEvent = "container.credentials"
ContainerPublishEvent = "container.publish"
ContainerRemoteBuildEvent = "container.remotebuild"
)
18 changes: 18 additions & 0 deletions cli/azd/internal/tracing/fields/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,24 @@ var (
}
)

// Deployment attributes
var (
// DeployAttemptKey tracks the retry attempt number for App Service zip deployments.
DeployAttemptKey = AttributeKey{
Key: attribute.Key("deploy.appservice.attempt"),
Classification: SystemMetadata,
Purpose: PerformanceAndHealth,
IsMeasurement: true,
}

// DeployLinuxKey tracks whether an App Service deployment targets a Linux web app.
DeployLinuxKey = AttributeKey{
Key: attribute.Key("deploy.appservice.linux"),
Classification: SystemMetadata,
Purpose: FeatureInsight,
}
)

// All possible enumerations of ExecutionEnvironmentKey
//
// Environments are mutually exclusive. Modifiers can be set additionally to signal different types of usages.
Expand Down
71 changes: 59 additions & 12 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 All @@ -44,7 +47,9 @@ type acrToken struct {

// ContainerRegistryService provides access to query and login to Azure Container Registries (ACR)
type ContainerRegistryService interface {
// Logs into the specified container registry
// Login authenticates to the specified container registry. The call is
// idempotent: concurrent callers for the same registry are deduplicated
// via singleflight, and subsequent calls return immediately from cache.
Login(ctx context.Context, subscriptionId string, loginServer string) error
// Gets the credentials that could be used to login to the specified container registry.
Credentials(ctx context.Context, subscriptionId string, loginServer string) (*DockerCredentials, error)
Expand All @@ -61,6 +66,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 +129,55 @@ 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. The 5-minute timeout is an upper bound that covers:
// - AAD token acquisition (~1-5s typical, up to 30s under load)
// - ACR token exchange via /oauth2/exchange (~1-3s typical)
// - docker login CLI invocation (~1-2s typical)
// Most logins complete in under 10s; 5 minutes provides generous headroom
// for transient AAD/ACR slowness without hanging indefinitely.
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
}

return nil
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)
}

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 Expand Up @@ -313,6 +359,7 @@ func (crs *containerRegistryService) getAcrToken(
if err != nil {
return nil, err
}
defer response.Body.Close()

if !azruntime.HasStatusCode(response, http.StatusOK) {
return nil, azruntime.NewResponseError(response)
Expand Down
Loading
Loading