Skip to content

Commit 35f97aa

Browse files
committed
fix: preserve ACR selections during environment refresh
1 parent 6677dff commit 35f97aa

4 files changed

Lines changed: 190 additions & 10 deletions

File tree

‎cli/azd/extensions/azure.ai.projects/README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,9 @@ legacy outputs; legacy ownership is verified against the previous Azure
366366
deployment before provisioning or deleting the group. Unreadable environment
367367
state or unverified ownership stops the operation instead of creating a
368368
replacement registry.
369+
For a deployed binding, `azd env refresh` also retains its registry ID,
370+
endpoint, and connection when the latest deployment has empty registry
371+
outputs. Refreshing does not change the binding or registry access.
369372
The external-registry build checks apply to `reuse-connect` selections, not
370373
azd-created registries retained through a `create` binding or matching legacy
371374
outputs. Managed registries can remain inside the Foundry resource group.
@@ -386,10 +389,11 @@ infrastructure-layer routes. An invalid final selection leaves those choices
386389
unpersisted.
387390

388391
You can select a registry before adding agents; the selection remains pending
389-
until a source-built agent requires it, including across project-only provisioning
390-
and teardown. Configured project or account names do not discard an unused
391-
selection. Source initialization validates the selector and rejects unknown
392-
application teardown scopes before changing files or environment values.
392+
until a source-built agent requires it, including across project-only provisioning,
393+
environment refresh, and teardown. Configured project or account names do not
394+
discard an unused selection. Source initialization validates the selector and
395+
rejects unknown application teardown scopes before changing files or environment
396+
values.
393397
An unused `AZURE_RESOURCE_GROUP` value does not add a teardown group when only
394398
a Foundry layer is configured.
395399

‎cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle.go‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,7 @@ func ownedACRAssignment(actual acrRoleAssignment, registryID, principalID, roleI
815815
actual.Properties.Condition == "" && actual.Properties.Description == "azd-foundry:"+token
816816
}
817817

818-
func (p *FoundryProvisioningProvider) persistACRBinding(
818+
func (p *FoundryProvisioningProvider) preserveACROutputs(
819819
ctx context.Context, outputs map[string]*azdext.ProvisioningOutputParameter,
820820
) error {
821821
if p.brownfieldEndpoint != "" {
@@ -844,6 +844,18 @@ func (p *FoundryProvisioningProvider) persistACRBinding(
844844
}
845845
}
846846
}
847+
return nil
848+
}
849+
850+
func (p *FoundryProvisioningProvider) persistACRBinding(
851+
ctx context.Context, outputs map[string]*azdext.ProvisioningOutputParameter,
852+
) error {
853+
if p.brownfieldEndpoint != "" {
854+
return nil
855+
}
856+
if err := p.preserveACROutputs(ctx, outputs); err != nil {
857+
return err
858+
}
847859
registry := outputs["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"]
848860
project := outputs["AZURE_AI_PROJECT_ID"]
849861
if registry == nil || registry.Value == "" || project == nil || project.Value == "" {

‎cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle_test.go‎

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,13 +1612,15 @@ func acrTestHTTPReply(t *testing.T, request *http.Request, code int, value any)
16121612

16131613
func TestACRProjectOnlyProvisionPreservesPendingSelector(t *testing.T) {
16141614
for _, tt := range []struct {
1615-
mode string
1616-
down bool
1615+
mode string
1616+
down, refresh bool
16171617
}{
16181618
{mode: ""}, {mode: "reuse-connect"},
16191619
{mode: "", down: true}, {mode: "reuse-connect", down: true},
1620+
{mode: "", refresh: true}, {mode: "reuse-connect", refresh: true},
1621+
{mode: "", down: true, refresh: true}, {mode: "reuse-connect", down: true, refresh: true},
16201622
} {
1621-
t.Run(tt.mode+"/down="+strconv.FormatBool(tt.down), func(t *testing.T) {
1623+
t.Run(fmt.Sprintf("%s/down=%t/refresh=%t", tt.mode, tt.down, tt.refresh), func(t *testing.T) {
16221624
root := t.TempDir()
16231625
manifest := "services:\n project:\n host: azure.ai.project\n"
16241626
require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte(manifest), 0o600))
@@ -1634,6 +1636,7 @@ func TestACRProjectOnlyProvisionPreservesPendingSelector(t *testing.T) {
16341636
&azdext.ProvisioningOptions{Provider: FoundryProviderName}))
16351637
require.Equal(t, "none", p.greenfieldAcrMode)
16361638
p.credential = acrTestCredential{}
1639+
var deployedOutputs map[string]any
16371640
p.armClientOptions = &arm.ClientOptions{ClientOptions: policy.ClientOptions{
16381641
Transport: acrTestTransport(func(request *http.Request) (*http.Response, error) {
16391642
if request.Method == http.MethodGet &&
@@ -1646,11 +1649,17 @@ func TestACRProjectOnlyProvisionPreservesPendingSelector(t *testing.T) {
16461649
}
16471650
require.Equal(t, "/subscriptions/sub/providers/Microsoft.Resources/deployments/"+
16481651
p.deploymentName(), request.URL.Path)
1652+
deployedOutputs = nil
16491653
return acrTestHTTPReply(t, request, http.StatusNoContent, nil)
16501654
}
16511655
require.Contains(t, request.URL.Path, "/providers/Microsoft.Resources/deployments/")
16521656
if request.Method == http.MethodGet {
1653-
return acrTestHTTPReply(t, request, http.StatusNotFound, nil)
1657+
if deployedOutputs == nil {
1658+
return acrTestHTTPReply(t, request, http.StatusNotFound, nil)
1659+
}
1660+
return acrTestHTTPReply(t, request, http.StatusOK, map[string]any{
1661+
"properties": map[string]any{"provisioningState": "Succeeded", "outputs": deployedOutputs},
1662+
})
16541663
}
16551664
require.Equal(t, http.MethodPut, request.Method)
16561665
outputs := map[string]any{
@@ -1663,6 +1672,7 @@ func TestACRProjectOnlyProvisionPreservesPendingSelector(t *testing.T) {
16631672
} {
16641673
outputs[key] = map[string]any{"type": "string", "value": ""}
16651674
}
1675+
deployedOutputs = outputs
16661676
return acrTestHTTPReply(t, request, http.StatusOK, map[string]any{
16671677
"properties": map[string]any{"provisioningState": "Succeeded", "outputs": outputs},
16681678
})
@@ -1686,6 +1696,28 @@ func TestACRProjectOnlyProvisionPreservesPendingSelector(t *testing.T) {
16861696
assert.Empty(t, env.config)
16871697
assert.Nil(t, p.acrBinding)
16881698

1699+
if tt.refresh {
1700+
refresher := &FoundryProvisioningProvider{
1701+
azdClient: p.azdClient, credential: p.credential, armClientOptions: p.armClientOptions,
1702+
}
1703+
require.NoError(t, refresher.Initialize(t.Context(), root,
1704+
&azdext.ProvisioningOptions{Provider: FoundryProviderName}))
1705+
env.configErr = errors.New("refresh must not persist registry lifecycle state")
1706+
state, err := refresher.State(t.Context(), &azdext.ProvisioningStateOptions{})
1707+
env.configErr = nil
1708+
require.NoError(t, err)
1709+
require.NotNil(t, state.State)
1710+
assert.Empty(t, env.config)
1711+
assert.Empty(t, env.set)
1712+
for key, output := range state.State.Outputs {
1713+
values[key] = output.Value
1714+
}
1715+
require.NoError(t, godotenv.Write(values, envPath))
1716+
env.get, err = godotenv.Read(envPath)
1717+
require.NoError(t, err)
1718+
require.Equal(t, testACRID, env.get["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"])
1719+
}
1720+
16891721
if tt.down {
16901722
destroyed, err := p.Destroy(t.Context(),
16911723
&azdext.ProvisioningDestroyOptions{Force: true, Purge: true}, func(string) {})
@@ -1715,6 +1747,134 @@ func TestACRProjectOnlyProvisionPreservesPendingSelector(t *testing.T) {
17151747
}
17161748
}
17171749

1750+
func TestACROutputPreservationKeepsCurrentOutputs(t *testing.T) {
1751+
for _, tt := range []struct {
1752+
name, registry, endpoint, connection string
1753+
brownfield bool
1754+
}{
1755+
{
1756+
name: "current registry", registry: testACRID,
1757+
endpoint: "current.azurecr.io", connection: "current-connection",
1758+
},
1759+
{
1760+
name: "current connection", endpoint: "current.azurecr.io", connection: "current-connection",
1761+
},
1762+
{name: "brownfield", brownfield: true},
1763+
} {
1764+
t.Run(tt.name, func(t *testing.T) {
1765+
p, env := acrTestProvider(t)
1766+
p.acrBinding = acrTestBinding()
1767+
if tt.brownfield {
1768+
p.brownfieldEndpoint = "https://existing.services.ai.azure.com/api/projects/project"
1769+
}
1770+
env.getErr = map[string]error{
1771+
"AZURE_CONTAINER_REGISTRY_ENDPOINT": errors.New("must not replace current outputs"),
1772+
"AZURE_AI_PROJECT_ACR_CONNECTION_NAME": errors.New("must not replace current outputs"),
1773+
}
1774+
outputs := map[string]*azdext.ProvisioningOutputParameter{
1775+
"AZURE_CONTAINER_REGISTRY_RESOURCE_ID": {Type: "string", Value: tt.registry},
1776+
"AZURE_CONTAINER_REGISTRY_ENDPOINT": {Type: "string", Value: tt.endpoint},
1777+
"AZURE_AI_PROJECT_ACR_CONNECTION_NAME": {Type: "string", Value: tt.connection},
1778+
}
1779+
require.NoError(t, p.preserveACROutputs(t.Context(), outputs))
1780+
registry := tt.registry
1781+
if !tt.brownfield {
1782+
registry = testACRID
1783+
}
1784+
assert.Equal(t, registry, outputs["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"].Value)
1785+
assert.Equal(t, tt.endpoint, outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"].Value)
1786+
assert.Equal(t, tt.connection, outputs["AZURE_AI_PROJECT_ACR_CONNECTION_NAME"].Value)
1787+
assert.Empty(t, env.set)
1788+
assert.Empty(t, env.config)
1789+
})
1790+
}
1791+
}
1792+
1793+
func TestACRStatePreservesDeployedRegistryOutputs(t *testing.T) {
1794+
for _, mode := range []string{"create", "reuse-connect"} {
1795+
for _, alias := range []bool{false, true} {
1796+
for _, failRead := range []string{"", "AZURE_CONTAINER_REGISTRY_ENDPOINT", "AZURE_AI_PROJECT_ACR_CONNECTION_NAME"} {
1797+
t.Run(fmt.Sprintf("%s/alias=%t/read=%s", mode, alias, failRead), func(t *testing.T) {
1798+
root := t.TempDir()
1799+
require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte(
1800+
"services:\n project:\n host: azure.ai.project\n"), 0o600))
1801+
p, env := acrTestProvider(t)
1802+
binding := acrTestBinding()
1803+
binding.Mode = mode
1804+
if mode == "create" {
1805+
binding.RegistryResourceID = strings.Replace(testACRID, "/shared/", "/foundry/", 1)
1806+
}
1807+
data, err := json.Marshal(binding)
1808+
require.NoError(t, err)
1809+
env.config = map[string][]byte{synthesis.ACRBindingConfigKey: data}
1810+
env.get = map[string]string{
1811+
envKeySubscriptionID: "sub", envKeyLocation: "eastus2",
1812+
envKeyResourceGroup: "foundry", envKeyProjectName: "project",
1813+
"AZD_FOUNDRY_ACR_MODE": mode,
1814+
"AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io",
1815+
"AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "registry-connection",
1816+
}
1817+
if alias {
1818+
env.get["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] = binding.RegistryResourceID
1819+
}
1820+
require.NoError(t, p.Initialize(t.Context(), root,
1821+
&azdext.ProvisioningOptions{Provider: FoundryProviderName}))
1822+
beforeValues, beforeConfig := maps.Clone(env.get), maps.Clone(env.config)
1823+
env.configErr = errors.New("state must not persist registry lifecycle state")
1824+
if failRead != "" {
1825+
env.getErr = map[string]error{failRead: errors.New("registry output unavailable")}
1826+
}
1827+
p.credential = acrTestCredential{}
1828+
reads := 0
1829+
p.armClientOptions = &arm.ClientOptions{ClientOptions: policy.ClientOptions{
1830+
Transport: acrTestTransport(func(request *http.Request) (*http.Response, error) {
1831+
require.Equal(t, http.MethodGet, request.Method)
1832+
require.Equal(t, "/subscriptions/sub/providers/Microsoft.Resources/deployments/"+
1833+
p.deploymentName(), request.URL.Path)
1834+
reads++
1835+
outputs := map[string]any{"AZURE_AI_PROJECT_ID": map[string]any{
1836+
"type": "string", "value": testACRProject,
1837+
}}
1838+
for _, key := range []string{
1839+
"AZURE_CONTAINER_REGISTRY_RESOURCE_ID", "AZURE_CONTAINER_REGISTRY_ENDPOINT",
1840+
"AZURE_AI_PROJECT_ACR_CONNECTION_NAME",
1841+
} {
1842+
outputs[key] = map[string]any{"type": "string", "value": ""}
1843+
}
1844+
return acrTestHTTPReply(t, request, http.StatusOK, map[string]any{
1845+
"properties": map[string]any{"provisioningState": "Succeeded", "outputs": outputs},
1846+
})
1847+
}),
1848+
}}
1849+
state, err := p.State(t.Context(), &azdext.ProvisioningStateOptions{})
1850+
if failRead != "" {
1851+
require.ErrorContains(t, err, "preserve deployed registry output")
1852+
require.ErrorContains(t, err, "registry output unavailable")
1853+
assert.Nil(t, state)
1854+
} else {
1855+
require.NoError(t, err)
1856+
require.NotNil(t, state.State)
1857+
for key, value := range map[string]string{
1858+
"AZURE_CONTAINER_REGISTRY_RESOURCE_ID": binding.RegistryResourceID,
1859+
"AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io",
1860+
"AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "registry-connection",
1861+
} {
1862+
require.Contains(t, state.State.Outputs, key)
1863+
assert.Equal(t, value, state.State.Outputs[key].Value)
1864+
}
1865+
}
1866+
assert.Equal(t, 1, reads)
1867+
assert.Equal(t, beforeValues, env.get)
1868+
assert.Equal(t, beforeConfig, env.config)
1869+
assert.Empty(t, env.set)
1870+
assert.Equal(t, binding, p.acrBinding)
1871+
assert.Nil(t, p.acrPending)
1872+
})
1873+
}
1874+
}
1875+
}
1876+
}
1877+
17181878
func TestACRFailedDeploymentRecoversIndependentOwnership(t *testing.T) {
17191879
for _, tt := range []struct {
17201880
name string

‎cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1271,9 +1271,13 @@ func (p *FoundryProvisioningProvider) State(
12711271
return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentGet)
12721272
}
12731273

1274+
outputs := p.normalizeOutputs(p.withTenantOutput(armOutputsToProto(deploymentOutputs(resp.Properties))))
1275+
if err := p.preserveACROutputs(ctx, outputs); err != nil {
1276+
return nil, err
1277+
}
12741278
return &azdext.ProvisioningStateResult{
12751279
State: &azdext.ProvisioningState{
1276-
Outputs: p.normalizeOutputs(p.withTenantOutput(armOutputsToProto(deploymentOutputs(resp.Properties)))),
1280+
Outputs: outputs,
12771281
Resources: armResourcesToProto(deploymentResources(resp.Properties)),
12781282
},
12791283
}, nil

0 commit comments

Comments
 (0)