Skip to content

Commit 0a113e6

Browse files
antriksh30Antriksh JainCopilot
authored
feat(ai-agents): fetch hosted-agent supported regions from manifest (#7930)
* feat(ai-agents): fetch hosted-agent supported regions from manifest Replace the hardcoded supportedHostedAgentRegions slice with a fetch from a JSON manifest committed in the repo. Adding or removing regions no longer requires shipping a new extension release. The manifest URL temporarily points at raw.githubusercontent.com on main; will switch to an aka.ms link once provisioned. * Address PR review feedback - Switch manifest URL to https://aka.ms/azd-ai-agents/regions and make it a var so tests can override it. - supportedModelLocations now returns a structured CodeNoSupportedModelLocations error when the intersection is empty (an empty allowlist would otherwise disable downstream filtering and let users pick unsupported regions). - init_models.go callsites handle CodeNoSupportedModelLocations gracefully by continuing the recovery loop with a helpful message instead of aborting. - TestSupportedRegionsForInit_FetchesOnceAndCaches now exercises the real cached-fetch path via a URL override; tests touching the shared regionsCache no longer run in parallel. - Added test asserting the structured error code. * Use errors.AsType in test to satisfy errorlint * Address review: release mutex during fetch + cap manifest body size Fetch the regions manifest without holding regionsCache.mu so a caller's canceled context returns immediately instead of waiting up to the fetch timeout. The fetch is coordinated via an in-flight handle so concurrent callers share a single network round-trip; on failure the in-flight slot clears so the next caller retries instead of latching the error. Also bound the response body with io.LimitReader (1 MiB cap) to guard against unexpectedly large or hostile responses from the source URL. Adds tests for the size cap and for concurrent callers sharing a single fetch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ai-agents): use embedded regions manifest as fetch fallback If the live manifest fetch fails (transient network, restrictive proxy, outage), fall back to the build-time embedded copy of hosted-agent-regions.json so 'azd init' is not blocked. The JSON moves into the cmd package because //go:embed cannot reference files outside the package directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ai-agents): propagate ctx into regions fetch goroutine (gosec G118) Use context.WithoutCancel(callerCtx) so the goroutine inherits ctx values without being abortable by any single caller. Resolves the gosec G118 lint failure flagging context.Background() inside the goroutine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Antriksh Jain <antrikshjain@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5f961f5 commit 0a113e6

6 files changed

Lines changed: 507 additions & 98 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"regions": [
3+
"australiaeast",
4+
"brazilsouth",
5+
"canadacentral",
6+
"eastus2",
7+
"francecentral",
8+
"japaneast",
9+
"koreacentral",
10+
"northcentralus",
11+
"norwayeast",
12+
"polandcentral",
13+
"southafricanorth",
14+
"southeastasia",
15+
"southindia",
16+
"spaincentral",
17+
"swedencentral",
18+
"switzerlandnorth",
19+
"westus",
20+
"westus3"
21+
]
22+
}

cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,10 @@ func ensureLocation(
735735
azureContext *azdext.AzureContext,
736736
envName string,
737737
) error {
738-
allowedLocations := supportedRegionsForInit()
738+
allowedLocations, err := supportedRegionsForInit(ctx)
739+
if err != nil {
740+
return err
741+
}
739742

740743
if azureContext.Scope.Location != "" && locationAllowed(azureContext.Scope.Location, allowedLocations) {
741744
return nil

cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go

Lines changed: 219 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,49 +3,225 @@
33

44
package cmd
55

6-
import "slices"
7-
8-
// No API available to query supported regions for hosted agents, so keep hardcoded list based on public documentation:
9-
// https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agents#region-availability
10-
var supportedHostedAgentRegions = []string{
11-
"australiaeast",
12-
"brazilsouth",
13-
"canadacentral",
14-
"canadaeast",
15-
"centralus",
16-
"eastus",
17-
"eastus2",
18-
"francecentral",
19-
"germanywestcentral",
20-
"italynorth",
21-
"japaneast",
22-
"koreacentral",
23-
"northcentralus",
24-
"norwayeast",
25-
"polandcentral",
26-
"southafricanorth",
27-
"southcentralus",
28-
"southeastasia",
29-
"southindia",
30-
"spaincentral",
31-
"swedencentral",
32-
"switzerlandnorth",
33-
"uaenorth",
34-
"uksouth",
35-
"westeurope",
36-
"westus",
37-
"westus3",
38-
}
39-
40-
func supportedRegionsForInit() []string {
41-
return slices.Clone(supportedHostedAgentRegions)
42-
}
43-
44-
// supportedModelLocations returns the intersection of a model's available locations
45-
// with the supported hosted agent regions.
46-
func supportedModelLocations(modelLocations []string) []string {
47-
supported := supportedRegionsForInit()
48-
return slices.DeleteFunc(slices.Clone(modelLocations), func(loc string) bool {
6+
import (
7+
"context"
8+
_ "embed"
9+
"encoding/json"
10+
"errors"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"slices"
15+
"sync"
16+
"time"
17+
18+
"azureaiagent/internal/exterrors"
19+
20+
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
21+
)
22+
23+
// hostedAgentRegionsURL points at the supported-regions manifest.
24+
// It is a var so tests can override it.
25+
var hostedAgentRegionsURL = "https://aka.ms/azd-ai-agents/regions"
26+
27+
// embeddedHostedAgentRegionsJSON is the build-time fallback used when the live
28+
// manifest fetch fails (e.g. transient network issues, restrictive proxies).
29+
//
30+
//go:embed hosted-agent-regions.json
31+
var embeddedHostedAgentRegionsJSON []byte
32+
33+
const (
34+
hostedAgentRegionsFetchTimeout = 5 * time.Second
35+
// hostedAgentRegionsManifestMaxBytes caps the manifest body to guard against
36+
// unexpectedly large responses from the source URL.
37+
hostedAgentRegionsManifestMaxBytes = 1 << 20 // 1 MiB
38+
)
39+
40+
type hostedAgentRegionsManifest struct {
41+
Regions []string `json:"regions"`
42+
}
43+
44+
var regionsCache struct {
45+
mu sync.Mutex
46+
regions []string
47+
inflight *regionsFetch
48+
}
49+
50+
// regionsFetch coordinates concurrent callers waiting on the same in-flight fetch
51+
// so the package-level mutex can be released while the network call is running.
52+
type regionsFetch struct {
53+
done chan struct{}
54+
regions []string
55+
err error
56+
}
57+
58+
// supportedRegionsForInit returns the list of Azure regions supported for hosted agents.
59+
// The result is cached for the process after the first successful fetch.
60+
//
61+
// The fetch itself is performed without holding regionsCache.mu so callers whose
62+
// context is canceled can return promptly even if another goroutine is mid-fetch.
63+
func supportedRegionsForInit(ctx context.Context) ([]string, error) {
64+
regionsCache.mu.Lock()
65+
if regionsCache.regions != nil {
66+
regions := slices.Clone(regionsCache.regions)
67+
regionsCache.mu.Unlock()
68+
return regions, nil
69+
}
70+
71+
fetch := regionsCache.inflight
72+
if fetch == nil {
73+
fetch = &regionsFetch{done: make(chan struct{})}
74+
regionsCache.inflight = fetch
75+
// context.WithoutCancel keeps any context values but drops cancellation,
76+
// because the fetch result is shared across all waiters and must not be
77+
// aborted by a single caller's cancellation.
78+
go runRegionsFetch(context.WithoutCancel(ctx), fetch)
79+
}
80+
regionsCache.mu.Unlock()
81+
82+
select {
83+
case <-ctx.Done():
84+
return nil, ctx.Err()
85+
case <-fetch.done:
86+
if fetch.err != nil {
87+
return nil, fetch.err
88+
}
89+
return slices.Clone(fetch.regions), nil
90+
}
91+
}
92+
93+
// runRegionsFetch performs the network fetch, populates the cache on success, and
94+
// signals all waiters via fetch.done. If the fetch fails, the embedded build-time
95+
// manifest is used as a fallback so a transient network issue doesn't halt init.
96+
//
97+
// ctx must not carry a cancellation that any single caller can trigger, since the
98+
// fetch result is shared. Callers pass context.WithoutCancel(callerCtx).
99+
func runRegionsFetch(ctx context.Context, fetch *regionsFetch) {
100+
// The fetch applies its own timeout (hostedAgentRegionsFetchTimeout).
101+
regions, err := fetchHostedAgentRegionsFromURL(ctx, http.DefaultClient, hostedAgentRegionsURL)
102+
103+
if err != nil {
104+
if fallback, fbErr := parseEmbeddedHostedAgentRegions(); fbErr == nil && len(fallback) > 0 {
105+
regions = fallback
106+
err = nil
107+
}
108+
}
109+
110+
regionsCache.mu.Lock()
111+
if err == nil {
112+
regionsCache.regions = regions
113+
}
114+
regionsCache.inflight = nil
115+
regionsCache.mu.Unlock()
116+
117+
fetch.regions = regions
118+
fetch.err = err
119+
close(fetch.done)
120+
}
121+
122+
// parseEmbeddedHostedAgentRegions decodes the embedded build-time manifest used
123+
// as a fallback when the live fetch fails.
124+
func parseEmbeddedHostedAgentRegions() ([]string, error) {
125+
var manifest hostedAgentRegionsManifest
126+
if err := json.Unmarshal(embeddedHostedAgentRegionsJSON, &manifest); err != nil {
127+
return nil, err
128+
}
129+
regions := make([]string, 0, len(manifest.Regions))
130+
for _, r := range manifest.Regions {
131+
if normalized := normalizeLocationName(r); normalized != "" {
132+
regions = append(regions, normalized)
133+
}
134+
}
135+
return regions, nil
136+
}
137+
138+
// supportedModelLocations returns the intersection of a model's available locations with
139+
// the supported hosted-agent regions. Returns an error when the intersection is empty
140+
// because passing an empty allowlist downstream disables filtering, which would let users
141+
// pick regions that are not supported for hosted agents.
142+
func supportedModelLocations(ctx context.Context, modelLocations []string) ([]string, error) {
143+
supported, err := supportedRegionsForInit(ctx)
144+
if err != nil {
145+
return nil, err
146+
}
147+
148+
result := slices.DeleteFunc(slices.Clone(modelLocations), func(loc string) bool {
49149
return !locationAllowed(loc, supported)
50150
})
151+
152+
if len(result) == 0 {
153+
return nil, exterrors.Dependency(
154+
exterrors.CodeNoSupportedModelLocations,
155+
"the selected model is not available in any region supported for hosted agents",
156+
"select a different model.",
157+
)
158+
}
159+
160+
return result, nil
161+
}
162+
163+
func fetchHostedAgentRegionsFromURL(ctx context.Context, httpClient *http.Client, url string) ([]string, error) {
164+
fetchCtx, cancel := context.WithTimeout(ctx, hostedAgentRegionsFetchTimeout)
165+
defer cancel()
166+
167+
req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
168+
if err != nil {
169+
return nil, regionsFetchError(err)
170+
}
171+
172+
//nolint:gosec // URL is the hardcoded hostedAgentRegionsURL constant or test override
173+
resp, err := httpClient.Do(req)
174+
if err != nil {
175+
return nil, regionsFetchError(err)
176+
}
177+
defer resp.Body.Close()
178+
179+
if resp.StatusCode != http.StatusOK {
180+
return nil, regionsFetchError(fmt.Errorf("unexpected HTTP status %d", resp.StatusCode))
181+
}
182+
183+
body, err := io.ReadAll(io.LimitReader(resp.Body, hostedAgentRegionsManifestMaxBytes+1))
184+
if err != nil {
185+
return nil, regionsFetchError(err)
186+
}
187+
if len(body) > hostedAgentRegionsManifestMaxBytes {
188+
return nil, regionsFetchError(fmt.Errorf(
189+
"manifest exceeds %d byte limit", hostedAgentRegionsManifestMaxBytes,
190+
))
191+
}
192+
193+
var manifest hostedAgentRegionsManifest
194+
if err := json.Unmarshal(body, &manifest); err != nil {
195+
return nil, regionsFetchError(err)
196+
}
197+
198+
regions := make([]string, 0, len(manifest.Regions))
199+
for _, r := range manifest.Regions {
200+
if normalized := normalizeLocationName(r); normalized != "" {
201+
regions = append(regions, normalized)
202+
}
203+
}
204+
205+
if len(regions) == 0 {
206+
return nil, regionsFetchError(fmt.Errorf("manifest contained no valid regions"))
207+
}
208+
209+
return regions, nil
210+
}
211+
212+
func regionsFetchError(err error) error {
213+
return exterrors.Dependency(
214+
exterrors.CodeRegionsFetchFailed,
215+
fmt.Sprintf("could not retrieve the list of supported Azure regions: %v", err),
216+
"check your network connection and try again. "+
217+
"If the issue persists, file an issue at https://github.com/Azure/azure-dev/issues",
218+
)
219+
}
220+
221+
// isNoSupportedLocationsError reports whether err is the structured error returned by
222+
// [supportedModelLocations] when no region in the model's location list is supported
223+
// for hosted agents.
224+
func isNoSupportedLocationsError(err error) bool {
225+
localErr, ok := errors.AsType[*azdext.LocalError](err)
226+
return ok && localErr.Code == exterrors.CodeNoSupportedModelLocations
51227
}

0 commit comments

Comments
 (0)