Skip to content

Commit 2c08fc7

Browse files
Antriksh JainCopilot
andcommitted
feat(azure.ai.agents): doctor local checks 1-3 (grpc-extension, azure-yaml, environment-selected)
Adds the first three local doctor checks plus their factory `NewLocalChecks` and supporting `Dependencies` struct (gRPC client + extension version), all in a new `internal/cmd/doctor/checks_local.go`. Cobra wiring lands in Phase 4.4; this commit is the check-implementation seam only — production already imports the package via Phase 4.1 / 4.1.x. What each check does - `local.grpc-extension` — verifies the gRPC channel to azd is healthy (`Dependencies.AzdClient != nil`) and that the extension version is at or above the new-hosted-agents backend floor (`MinNewBackendVersion = "0.1.27-preview"`, per the quickstart docs). Below-floor is a Warn (legacy ACA backend still works); dev/empty version strings skip the floor comparison cleanly. - `local.azure-yaml` — calls `Project().Get(ctx, &EmptyRequest{})`. Fails on gRPC error or nil Project. Suggestion mirrors `helpers.go`'s `resolveConfigPath` wording verbatim for cross-command consistency ("Run from a directory containing `azure.yaml`, or initialize one with `azd init`."). Skips cleanly when the gRPC channel is unavailable — no cascading failures. - `local.environment-selected` — calls `Environment().GetCurrent`. Fails on gRPC error or nil/empty Name. Skips when `local.azure-yaml` failed (env selection makes no sense without a project to anchor it). Three empty-name shapes are covered: nil response wrapper, nil Environment, empty Name string. Version comparator A tiny in-package `compareVersions` plus `parseMainVersion` handles the floor check on `<major>.<minor>.<patch>` ignoring any `-suffix`/`+build` metadata. Fail-open on parse errors (returns 0) so a malformed version string can never trigger a spurious "upgrade" Warning. Pulling in `Masterminds/semver` for one comparison was overkill; the constraint shape ("major.minor.patch[-preview]") is stable and the comparator is ten lines. Testing Tests use the local-listener gRPC pattern that already lives in `init_foundry_resources_helpers_test.go:232-271` (`grpc.NewServer` + `net.Listen("tcp", "127.0.0.1:0")` + stub server methods + `t.Cleanup`). Kept the test helper local to the doctor package to avoid a cross-package test-only import. 26 new test functions / subtests covering: gRPC nil + nil-error, dev-build version skip, version floor below/equal/above, project gRPC error / nil response / nil Project / pass, env gRPC error / 3 empty-name shapes / cascade-skip / pass, comparator (10 cases incl. fail-open), parser (6 cases), and `NewLocalChecks` ID/Name/Remote/Fn-ordering. Pre-flight clean: gofmt, vet, build, full extension test suite green (cmd 15.1s, doctor 5.5s, nextstep 5.4s, agent_api 11.5s, ...), golangci-lint 0 issues, cspell 0 issues, copyright headers present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4e293f7 commit 2c08fc7

2 files changed

Lines changed: 704 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package doctor
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
13+
)
14+
15+
// MinNewBackendVersion is the floor extension version required to talk to
16+
// the new hosted-agents backend. Extensions below this floor can still
17+
// drive the legacy ACA backend; the floor is advisory, surfaced as a
18+
// Warning rather than a hard Fail. The constant lives next to its sole
19+
// consumer (Check `local.grpc-extension`) so that bumping it is a
20+
// one-line change with no scattered references.
21+
//
22+
// Source: hosted-agents quickstart docs at
23+
// https://learn.microsoft.com/azure/foundry/agents/quickstarts/quickstart-hosted-agent
24+
const MinNewBackendVersion = "0.1.27-preview"
25+
26+
// Dependencies bundles the runtime services local checks consume. The
27+
// Cobra wiring in the parent internal/cmd package constructs this from
28+
// `azdext.NewAzdClient()` and the extension's compiled-in version
29+
// constant; tests inject directly.
30+
//
31+
// AzdClient may be nil if NewAzdClient failed at startup (e.g. when the
32+
// extension is launched outside `azd ext run`). AzdClientErr captures
33+
// the cause so Check `local.grpc-extension` can surface it verbatim.
34+
// Downstream checks that need the client must Skip cleanly rather than
35+
// Fail — a cascade of identical "no client" failures is noise.
36+
type Dependencies struct {
37+
AzdClient *azdext.AzdClient
38+
AzdClientErr error
39+
ExtensionVersion string
40+
}
41+
42+
// NewLocalChecks returns the canonical sequence of local doctor checks
43+
// in execution order. Phase 4.2 covers checks 1-3; phase 4.3 will append
44+
// checks 4-6 (agent service, project endpoint, agent.yaml).
45+
func NewLocalChecks(deps Dependencies) []Check {
46+
return []Check{
47+
newCheckGRPCAndVersion(deps),
48+
newCheckProjectConfig(deps),
49+
newCheckEnvironmentSelected(deps),
50+
}
51+
}
52+
53+
// newCheckGRPCAndVersion produces Check `local.grpc-extension`. It
54+
// verifies the gRPC channel back to azd is available (NewAzdClient
55+
// returned a non-nil client) and that the extension is at or above the
56+
// new-hosted-agents backend floor. Below the floor the check Warns —
57+
// the legacy ACA backend continues to work and the user does not need
58+
// to upgrade immediately.
59+
//
60+
// Dev builds (Version == "dev" or empty) skip the floor check: there is
61+
// no reliable comparison and a Warning on every developer iteration is
62+
// noise.
63+
func newCheckGRPCAndVersion(deps Dependencies) Check {
64+
return Check{
65+
ID: "local.grpc-extension",
66+
Name: "azd extension reachable",
67+
Fn: func(_ context.Context, _ Options, _ []Result) Result {
68+
if deps.AzdClient == nil {
69+
msg := "gRPC channel to azd is unavailable"
70+
if deps.AzdClientErr != nil {
71+
msg = fmt.Sprintf("gRPC channel to azd unavailable: %v", deps.AzdClientErr)
72+
}
73+
return Result{
74+
Status: StatusFail,
75+
Message: msg,
76+
Suggestion: "Run the extension via `azd ai agent doctor` (not the extension binary directly) and ensure azd is at least 1.24.0.",
77+
}
78+
}
79+
80+
ver := strings.TrimSpace(deps.ExtensionVersion)
81+
if ver == "" || ver == "dev" {
82+
return Result{
83+
Status: StatusPass,
84+
Message: fmt.Sprintf("azd extension reachable (version: %s).", coalesce(ver, "unknown")),
85+
}
86+
}
87+
88+
if compareVersions(ver, MinNewBackendVersion) < 0 {
89+
return Result{
90+
Status: StatusWarn,
91+
Message: fmt.Sprintf(
92+
"Extension version %s is older than %s; the new hosted-agents backend requires the floor.",
93+
ver, MinNewBackendVersion),
94+
Suggestion: "Upgrade with `azd ext upgrade azure.ai.agents`.",
95+
Links: []string{"https://aka.ms/hostedagents/tsg/readme"},
96+
Details: map[string]any{
97+
"extensionVersion": ver,
98+
"minBackendVersion": MinNewBackendVersion,
99+
},
100+
}
101+
}
102+
103+
return Result{
104+
Status: StatusPass,
105+
Message: fmt.Sprintf("azd extension reachable (version %s).", ver),
106+
}
107+
},
108+
}
109+
}
110+
111+
// newCheckProjectConfig produces Check `local.azure-yaml`. It probes the
112+
// azd Project service for the resolved project config. The check Fails
113+
// when the call returns an error OR the response carries a nil Project
114+
// (azd's convention for "no azure.yaml in the working directory"). The
115+
// suggestion mirrors the wording used in helpers.go's resolveConfigPath
116+
// so users see consistent guidance across commands.
117+
//
118+
// Skips cleanly when the gRPC client is unavailable — Check
119+
// `local.grpc-extension` will already have failed and produced the
120+
// actionable error.
121+
func newCheckProjectConfig(deps Dependencies) Check {
122+
return Check{
123+
ID: "local.azure-yaml",
124+
Name: "azure.yaml present and parseable",
125+
Fn: func(ctx context.Context, _ Options, _ []Result) Result {
126+
if deps.AzdClient == nil {
127+
return Result{
128+
Status: StatusSkip,
129+
Message: "skipped: azd extension not reachable",
130+
}
131+
}
132+
133+
resp, err := deps.AzdClient.Project().Get(ctx, &azdext.EmptyRequest{})
134+
if err != nil {
135+
return Result{
136+
Status: StatusFail,
137+
Message: fmt.Sprintf("failed to get project config: %v", err),
138+
Suggestion: "Run from a directory containing `azure.yaml`, or initialize one with `azd init`.",
139+
}
140+
}
141+
if resp == nil || resp.Project == nil {
142+
return Result{
143+
Status: StatusFail,
144+
Message: "failed to get project config (is there an azure.yaml?)",
145+
Suggestion: "Run from a directory containing `azure.yaml`, or initialize one with `azd init`.",
146+
}
147+
}
148+
149+
return Result{
150+
Status: StatusPass,
151+
Message: fmt.Sprintf("azure.yaml parsed (project: %s).", resp.Project.Name),
152+
Details: map[string]any{
153+
"projectPath": resp.Project.Path,
154+
"projectName": resp.Project.Name,
155+
},
156+
}
157+
},
158+
}
159+
}
160+
161+
// newCheckEnvironmentSelected produces Check
162+
// `local.environment-selected`. It probes the azd Environment service
163+
// for the currently-selected environment. The check Fails when the call
164+
// errors, or when the response carries a nil Environment / empty Name.
165+
//
166+
// Skips cleanly when the gRPC client is unavailable OR when the
167+
// `local.azure-yaml` check failed — environment selection is meaningless
168+
// without a project to anchor it.
169+
func newCheckEnvironmentSelected(deps Dependencies) Check {
170+
return Check{
171+
ID: "local.environment-selected",
172+
Name: "azd environment selected",
173+
Fn: func(ctx context.Context, _ Options, prior []Result) Result {
174+
if deps.AzdClient == nil {
175+
return Result{
176+
Status: StatusSkip,
177+
Message: "skipped: azd extension not reachable",
178+
}
179+
}
180+
for _, p := range prior {
181+
if p.ID == "local.azure-yaml" && p.Status == StatusFail {
182+
return Result{
183+
Status: StatusSkip,
184+
Message: "skipped: azure.yaml check failed",
185+
}
186+
}
187+
}
188+
189+
resp, err := deps.AzdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
190+
if err != nil {
191+
return Result{
192+
Status: StatusFail,
193+
Message: fmt.Sprintf("failed to get current environment: %v", err),
194+
Suggestion: "Create one with `azd env new <name>` or select an existing one with `azd env select <name>`.",
195+
}
196+
}
197+
if resp == nil || resp.Environment == nil || resp.Environment.Name == "" {
198+
return Result{
199+
Status: StatusFail,
200+
Message: "no azd environment is selected",
201+
Suggestion: "Create one with `azd env new <name>` or select an existing one with `azd env select <name>`.",
202+
}
203+
}
204+
205+
return Result{
206+
Status: StatusPass,
207+
Message: fmt.Sprintf("environment selected: %s.", resp.Environment.Name),
208+
Details: map[string]any{
209+
"environmentName": resp.Environment.Name,
210+
},
211+
}
212+
},
213+
}
214+
}
215+
216+
// coalesce returns the first non-empty string in values, or "" if all
217+
// are empty. Used to keep the version-floor check's Pass message
218+
// readable when the version string is blank.
219+
func coalesce(values ...string) string {
220+
for _, v := range values {
221+
if v != "" {
222+
return v
223+
}
224+
}
225+
return ""
226+
}
227+
228+
// compareVersions compares two version strings numerically on the first
229+
// three dotted components, ignoring any "-suffix" pre-release or "+build"
230+
// metadata. A leading "v" is tolerated. Returns -1 if a<b, 0 if a==b or
231+
// either side fails to parse, +1 if a>b.
232+
//
233+
// The fail-open behavior on invalid input is deliberate: a malformed
234+
// version string should never trigger a Warning suggesting the user
235+
// "upgrade" — a noisy Warn for a real bug is worse than a missed Warn for
236+
// a malformed string. Callers that need strict comparison should use a
237+
// real semver library; for the doctor's floor check, three-component
238+
// numeric comparison is sufficient (the pre-release suffix `-preview` is
239+
// shared between extension and floor and therefore lexicographically
240+
// equal — irrelevant to the cmp).
241+
func compareVersions(a, b string) int {
242+
pa, oka := parseMainVersion(a)
243+
pb, okb := parseMainVersion(b)
244+
if !oka || !okb {
245+
return 0
246+
}
247+
for i := range 3 {
248+
if pa[i] < pb[i] {
249+
return -1
250+
}
251+
if pa[i] > pb[i] {
252+
return 1
253+
}
254+
}
255+
return 0
256+
}
257+
258+
// parseMainVersion splits "v?X.Y.Z[-suffix][+build]" into [X, Y, Z] as
259+
// non-negative integers. Returns (zero, false) on any parse error.
260+
func parseMainVersion(v string) ([3]int, bool) {
261+
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
262+
if i := strings.IndexAny(v, "-+"); i >= 0 {
263+
v = v[:i]
264+
}
265+
parts := strings.SplitN(v, ".", 3)
266+
if len(parts) != 3 {
267+
return [3]int{}, false
268+
}
269+
var out [3]int
270+
for i, p := range parts {
271+
n, err := strconv.Atoi(p)
272+
if err != nil || n < 0 {
273+
return [3]int{}, false
274+
}
275+
out[i] = n
276+
}
277+
return out, true
278+
}

0 commit comments

Comments
 (0)