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
32 changes: 30 additions & 2 deletions cli/azd/extensions/azure.ai.agents/docs/infrastructure-eject.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ infra:

The existing `infra/main.bicep` remains unchanged.

For a service that sets `endpoint:`, Bicep eject keeps the
`microsoft.foundry` provider. The provider compiles the generated `main.bicep`,
so ejected and embedded provisioning use the same resource graph. Terraform
eject uses the built-in `terraform` provider.

## Existing files

Eject never overwrites generated-file collisions.
Expand Down Expand Up @@ -71,6 +76,27 @@ that it created the group and the live Azure group is tagged for the current
environment. Otherwise teardown intentionally refuses deletion so a user-owned
group is not removed.

For an existing-project Bicep eject, the account and project resource group are
never created by the generated template. Teardown can remove adjunct resources
created by that template, but does not own or delete the reused account or project.

## Existing container registries

Eject preserves the registry choice made during init:

- No registry required: no registry resources are managed.
- No existing registry selected: create a registry, `AcrPull` assignment, and
project connection.
- Existing registry without a project connection: reference the registry and
create only `AcrPull` plus the project connection.
- Existing project connection selected: reference its registry and connection
without managing either one.

The generated files never import or take ownership of an existing registry.
When registry work is required, the generated file is consistently named
`modules/container-registry.bicep` or `container-registry.tf`, whether the
registry is created or reused.

## Layer dependencies

The Foundry layer is independent by default. Azd analyzes generated parameter
Expand Down Expand Up @@ -100,7 +126,9 @@ value.

- Terraform eject does not support a service with a private `network:` block;
use Bicep for private networking.
- Brownfield services that set `endpoint:` reuse an existing Foundry project
and cannot eject infrastructure for that externally owned resource.
- Services that set `endpoint:` to reuse an existing project can eject Bicep or Terraform. The
generated templates reference the existing account and project without taking
ownership and manage only declared model deployments, connections, and
adjunct resources such as ACR.
- Eject preserves the existing root infrastructure mapping during migration;
custom properties remain the project owner's responsibility.
35 changes: 23 additions & 12 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,13 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
--image myacr.azurecr.io/agents/my-agent:v1`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := azdext.WithAccessToken(cmd.Context())
azdClient, err := azdext.NewAzdClient()
if err != nil {
return exterrors.Internal(exterrors.CodeAzdClientFailed, fmt.Sprintf("failed to create azd client: %s", err))
}
defer azdClient.Close()

flags.noPrompt = extCtx.NoPrompt
if flags.env == "" {
flags.env = extCtx.Environment
Expand Down Expand Up @@ -1278,19 +1285,23 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
if err := validateStandaloneEjectArgs(cmd, args); err != nil {
return err
}
return ejectInfra(gate.projectRoot, infraProvider)
var env map[string]string
needsEnv, err := infraEjectNeedsEnvironment(gate.projectRoot)
if err != nil {
return err
}
if needsEnv {
env, err = readInfraEjectEnvironment(ctx, azdClient)
if err != nil {
return err
}
}
return ejectInfra(gate.projectRoot, infraProvider, env)
}
}

ctx := azdext.WithAccessToken(cmd.Context())
flags.agentNameExplicit = cmd.Flags().Changed("agent-name")

azdClient, err := azdext.NewAzdClient()
if err != nil {
return exterrors.Internal(exterrors.CodeAzdClientFailed, fmt.Sprintf("failed to create azd client: %s", err))
}
defer azdClient.Close()

if err := checkAiModelServiceAvailable(ctx, azdClient); err != nil {
return err
}
Expand Down Expand Up @@ -1503,7 +1514,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
); err != nil {
return err
}
return ejectInfraAfterInit(infraProvider)
return ejectInfraAfterInit(ctx, infraProvider, azdClient)
}
}
}
Expand Down Expand Up @@ -1552,7 +1563,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
if err := runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing); err != nil {
return err
}
return ejectInfraAfterInit(infraProvider)
return ejectInfraAfterInit(ctx, infraProvider, azdClient)
}
}
}
Expand Down Expand Up @@ -1597,7 +1608,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
}
return err
}
return ejectInfraAfterInit(infraProvider)
return ejectInfraAfterInit(ctx, infraProvider, azdClient)
}
return missingAgentServiceError(flags.manifestPointer)
}
Expand Down Expand Up @@ -1867,7 +1878,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
// wrote azure.yaml, chain the eject step. Skip silently when init
// didn't produce a foundry-bearing azure.yaml (cancelled or
// non-foundry flow) to avoid a confusing "nothing to eject" error.
return ejectInfraAfterInit(infraProvider)
return ejectInfraAfterInit(ctx, infraProvider, azdClient)
},
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import (
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3"
armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
Expand All @@ -37,6 +39,7 @@ type FoundryProjectInfo struct {
// NetworkInjected is true when the owning Foundry account has VNET network
// injection (agent scenario); used to disable remote build.
NetworkInjected bool
PrincipalId string
}

// Endpoint returns the Foundry project data-plane endpoint derived from the
Expand Down Expand Up @@ -177,6 +180,9 @@ func updateFoundryProjectInfo(project *FoundryProjectInfo, resource *armcognitiv
if resource.Location != nil {
project.Location = *resource.Location
}
if resource.Identity != nil && resource.Identity.PrincipalID != nil {
project.PrincipalId = *resource.Identity.PrincipalID
}
}

// listFoundryProjects enumerates all Foundry projects in a subscription by listing
Expand Down Expand Up @@ -373,6 +379,39 @@ func listAcrResourceIds(
return resourceIds, nil
}

const acrPullRoleDefinitionID = "7f951dda-4ed3-4680-a7ca-43fe172d538d"

func hasAcrPullAssignment(
ctx context.Context,
credential azcore.TokenCredential,
resourceID string,
principalID string,
) (bool, error) {
subscriptionID := extractSubscriptionId(resourceID)
client, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, credential, azure.NewArmClientOptions())
if err != nil {
return false, fmt.Errorf("create role assignments client: %w", err)
}
filter := fmt.Sprintf("assignedTo('%s')", principalID)
pager := client.NewListForScopePager(resourceID, &armauthorization.RoleAssignmentsClientListForScopeOptions{
Filter: &filter,
})
roleSuffix := "/roleDefinitions/" + acrPullRoleDefinitionID
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return false, fmt.Errorf("list ACR role assignments: %w", err)
}
for _, assignment := range page.Value {
if assignment.Properties != nil && assignment.Properties.RoleDefinitionID != nil &&
strings.HasSuffix(*assignment.Properties.RoleDefinitionID, roleSuffix) {
return true, nil
}
}
}
return false, nil
}

// configureFoundryProjectEnv sets all Foundry project environment variables and discovers
// ACR and AppInsights connections. This is the shared implementation used by both init flows.
// When skipACR is true, ACR connection discovery and configuration is skipped (used for code deploy).
Expand Down Expand Up @@ -426,7 +465,7 @@ func configureFoundryProjectEnv(
// The provisioning provider owns ACR/AppInsights for a new project, but a
// container agent on an existing project needs a registry it won't create.
if skipACR {
return nil
return setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "none")
}
return configureExistingProjectAcr(ctx, azdClient, credential, envName, project, subscriptionId)
}
Expand Down Expand Up @@ -467,6 +506,8 @@ func configureFoundryProjectEnv(
if err := configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections); err != nil {
return err
}
} else if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "none"); err != nil {
return err
}

if err := configureAppInsightsConnection(ctx, azdClient, envName, appInsightsConnections); err != nil {
Expand Down Expand Up @@ -506,7 +547,8 @@ func configureExistingProjectAcr(
}
}

return configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections)
return configureAcrConnectionWithPrincipal(
ctx, azdClient, credential, envName, subscriptionId, acrConnections, project.PrincipalId)
}

// configureAcrConnection handles ACR connection selection and env var setting.
Expand All @@ -517,9 +559,22 @@ func configureAcrConnection(
envName string,
subscriptionId string,
acrConnections []azure.Connection,
) error {
return configureAcrConnectionWithPrincipal(ctx, azdClient, credential, envName, subscriptionId, acrConnections, "")
}

func configureAcrConnectionWithPrincipal(
ctx context.Context,
azdClient *azdext.AzdClient,
credential azcore.TokenCredential,
envName string,
subscriptionId string,
acrConnections []azure.Connection,
projectPrincipalID string,
) error {
return configureAcrConnectionWithRegistryLoader(
ctx, azdClient, credential, envName, subscriptionId, acrConnections, listAcrResourceIds,
projectPrincipalID,
)
}

Expand All @@ -538,7 +593,16 @@ func configureAcrConnectionWithRegistryLoader(
subscriptionId string,
acrConnections []azure.Connection,
loadRegistries acrRegistryLoader,
projectPrincipalIDs ...string,
) error {
previousValues, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{Name: envName})
if err != nil {
return fmt.Errorf("reading existing ACR ownership state: %w", err)
}
previous := make(map[string]string, len(previousValues.KeyValues))
for _, value := range previousValues.KeyValues {
previous[value.Key] = strings.TrimSpace(value.Value)
}
resourceIds, err := loadRegistries(ctx, credential, subscriptionId)
if err != nil {
return fmt.Errorf("listing container registries for connection validation: %w", err)
Expand Down Expand Up @@ -597,9 +661,22 @@ func configureAcrConnectionWithRegistryLoader(
if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", ""); err != nil {
return err
}
assigned := false
if len(projectPrincipalIDs) > 0 && projectPrincipalIDs[0] != "" {
assigned, err = hasAcrPullAssignment(ctx, credential, resourceId, projectPrincipalIDs[0])
if err != nil {
return fmt.Errorf("check existing AcrPull assignment: %w", err)
}
}
if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_PULL_ASSIGNED", fmt.Sprint(assigned)); err != nil {
return err
}
if err := updatePendingACRSignal(ctx, azdClient, envName, true); err != nil {
log.Printf("warning: failed to update acr provision signal: %v", err)
}
if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "reuse-connect"); err != nil {
return err
}
} else {
for _, key := range []string{
"AZURE_CONTAINER_REGISTRY_ENDPOINT",
Expand All @@ -613,6 +690,12 @@ func configureAcrConnectionWithRegistryLoader(
if err := updatePendingACRSignal(ctx, azdClient, envName, false); err != nil {
log.Printf("warning: failed to update acr provision signal: %v", err)
}
if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "create"); err != nil {
return err
}
if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_PULL_ASSIGNED", "false"); err != nil {
return err
}
}
return nil
}
Expand Down Expand Up @@ -681,10 +764,46 @@ func configureAcrConnectionWithRegistryLoader(
if err := updatePendingACRSignal(ctx, azdClient, envName, true); err != nil {
log.Printf("warning: failed to update acr provision signal: %v", err)
}
if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "already-connected"); err != nil {
return err
}
if shouldPreserveCreatedAcrMode(previous, *selectedConnection) {
if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "create"); err != nil {
return err
}
}
if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_PULL_ASSIGNED", "true"); err != nil {
return err
}

return nil
}

func shouldPreserveCreatedAcrMode(previous map[string]string, selected validatedAcrConnection) bool {
if previous["AZD_FOUNDRY_ACR_MODE"] != "create" {
return false
}
resourceID := strings.TrimSuffix(strings.TrimSpace(selected.resourceId), "/")
previousResourceID := strings.TrimSuffix(previous["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"], "/")
if resourceID == "" || !strings.EqualFold(resourceID, previousResourceID) {
return false
}
registry, err := arm.ParseResourceID(resourceID)
if err != nil || registry.ResourceType.String() != "Microsoft.ContainerRegistry/registries" {
return false
}
if !resourceGroupIDMatches(
previous["AZD_FOUNDRY_RESOURCE_GROUP_ID"], registry.SubscriptionID, registry.ResourceGroupName) {
return false
}
return registry.Name != "" && strings.EqualFold(selected.connection.Name, registry.Name+"-conn")
}

func resourceGroupIDMatches(resourceID, subscriptionID, resourceGroup string) bool {
wanted := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", subscriptionID, resourceGroup)
return resourceID != "" && strings.EqualFold(strings.TrimSuffix(resourceID, "/"), wanted)
}

// tracingOverviewURL points to an overview of agent tracing/telemetry behavior.
const tracingOverviewURL = "https://aka.ms/tracing-overview"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,23 @@ func TestConfigureAcrConnection_ValidatesDiscoveredConnections(t *testing.T) {
"AZURE_CONTAINER_REGISTRY_RESOURCE_ID": resourceId,
},
},
{
name: "re-init preserves create mode for the owned registry connection",
connections: []azure.Connection{{
Name: "valid-conn", Target: "valid.azurecr.io",
}},
registries: map[string]string{"valid.azurecr.io": resourceId},
initial: map[string]string{
"AZD_FOUNDRY_ACR_MODE": "create",
"AZD_FOUNDRY_RESOURCE_GROUP_ID": "/subscriptions/sub/resourceGroups/rg",
"AZURE_CONTAINER_REGISTRY_RESOURCE_ID": resourceId,
},
wantValues: map[string]string{
"AZD_FOUNDRY_ACR_MODE": "create",
"AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "valid-conn",
"AZURE_CONTAINER_REGISTRY_RESOURCE_ID": resourceId,
},
},
{
name: "stale sole connection falls back to create on provision and clears stale values",
connections: []azure.Connection{{
Expand Down
Loading
Loading