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
5 changes: 5 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,11 @@ func (a *InitAction) Run(ctx context.Context) error {
return fmt.Errorf("failed to add agent to azure.yaml: %w", err)
}

// Run post-init validations (advisory warnings only)
if ca, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok {
validatePostInit(targetDir, ca.CodeConfiguration)
}

color.Green("\nAI agent definition added to your azd project successfully!")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error {
fmt.Printf(" %s %s\n", color.GreenString("+"), color.GreenString("%s/agent.yaml", srcDir))
}

// Run post-init validations (advisory warnings only)
validatePostInit(srcDir, localDefinition.CodeConfiguration)

fmt.Println("\nYou can customize environment variables and other settings in the agent.yaml.")
if projectID, _ := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
EnvName: a.environment.Name,
Expand Down
124 changes: 124 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"

"azureaiagent/internal/pkg/agents/agent_yaml"

"github.com/fatih/color"
)

// validatePostInit runs all post-init validations and prints errors (non-blocking).
// Validations are advisory — they highlight issues that will cause deploy failures
// but do not prevent init from completing.
func validatePostInit(srcDir string, codeConfig *agent_yaml.CodeConfiguration) {
if codeConfig == nil {
return
}

validateDotnetRuntimeVsCsproj(srcDir, codeConfig.Runtime)
}

// validateDotnetRuntimeVsCsproj checks whether the selected .NET runtime version is compatible
// with the TargetFramework declared in the .csproj file. Prints an error (non-blocking) if:
// - The .csproj cannot be read (user should verify their project structure)
// - The .csproj targets a higher framework version than the selected runtime
func validateDotnetRuntimeVsCsproj(srcDir string, runtime string) {
if !strings.HasPrefix(runtime, "dotnet_") {
return
}

// Parse selected runtime version (e.g. "dotnet_9" -> 9, "dotnet_10" -> 10)
runtimeVersionStr := strings.TrimPrefix(runtime, "dotnet_")
runtimeVersion, err := strconv.Atoi(runtimeVersionStr)
if err != nil {
return
}

// Find .csproj file in srcDir
entries, err := os.ReadDir(srcDir)
if err != nil {
fmt.Printf("\n%s Could not read project directory %q to validate .NET TargetFramework. "+
"Please verify your .csproj TargetFramework matches the selected .NET %d runtime before deploying.\n",
color.RedString("ERROR:"),
srcDir, runtimeVersion,
)
return
}

var csprojFound bool
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".csproj") {
continue
}
csprojFound = true

csprojPath := filepath.Join(srcDir, e.Name())
data, err := os.ReadFile(csprojPath) //nolint:gosec // path from user project
if err != nil {
fmt.Printf("\n%s Could not read %s to validate TargetFramework. "+
"Please verify it targets net%d.0 or lower before deploying.\n",
color.RedString("ERROR:"),
e.Name(), runtimeVersion,
)
return
}

targetVersion := extractTargetFrameworkVersion(string(data))
if targetVersion <= 0 {
fmt.Printf("\n%s Could not parse TargetFramework from %s. "+
"Please verify it targets net%d.0 or lower before deploying.\n",
color.RedString("ERROR:"),
e.Name(), runtimeVersion,
)
return
}

if targetVersion > runtimeVersion {
fmt.Printf("\n%s %s targets net%d.0 but selected runtime is .NET %d. "+
"This will fail during build/deploy.\n"+
" Fix: Change <TargetFramework> in %s to net%d.0, or re-run init and select .NET %d runtime.\n",
color.RedString("ERROR:"),
e.Name(), targetVersion, runtimeVersion,
e.Name(), runtimeVersion,
targetVersion,
)
} else {
fmt.Printf("\n%s .NET runtime validation passed: %s targets net%d.0, selected runtime is .NET %d.\n",
color.GreenString("OK:"),
e.Name(), targetVersion, runtimeVersion,
)
}
return // only check the first .csproj found
}

if !csprojFound {
// No .csproj in directory — not necessarily an error for dotnet code deploy
// (could be a pre-compiled DLL scenario), so skip silently.
return
}
}

// extractTargetFrameworkVersion parses the major version number from a .csproj TargetFramework element.
// e.g. "<TargetFramework>net10.0</TargetFramework>" -> 10
// Returns 0 if not found or not parsable.
func extractTargetFrameworkVersion(csprojContent string) int {
re := regexp.MustCompile(`<TargetFramework>net(\d+)\.\d+[^<]*</TargetFramework>`)
matches := re.FindStringSubmatch(csprojContent)
if len(matches) < 2 {
return 0
}
version, err := strconv.Atoi(matches[1])
if err != nil {
return 0
}
return version
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

"azureaiagent/internal/pkg/agents/agent_yaml"
)

func TestExtractTargetFrameworkVersion(t *testing.T) {
tests := []struct {
name string
content string
want int
}{
{"net8.0", `<TargetFramework>net8.0</TargetFramework>`, 8},
{"net9.0", `<Project><PropertyGroup><TargetFramework>net9.0</TargetFramework></PropertyGroup></Project>`, 9},
{"net10.0", `<Project><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>`, 10},
{"no target framework", `<Project><PropertyGroup></PropertyGroup></Project>`, 0},
{"empty", "", 0},
{"netstandard", `<TargetFramework>netstandard2.0</TargetFramework>`, 0},
{"netcoreapp3.1", `<TargetFramework>netcoreapp3.1</TargetFramework>`, 0},
{"net8.0-windows", `<TargetFramework>net8.0-windows</TargetFramework>`, 8},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractTargetFrameworkVersion(tt.content)
if got != tt.want {
t.Errorf("extractTargetFrameworkVersion() = %d, want %d", got, tt.want)
}
})
}
}

func TestValidateDotnetRuntimeVsCsproj_Mismatch(t *testing.T) {
dir := t.TempDir()
csproj := `<Project><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>`
err := os.WriteFile(filepath.Join(dir, "MyAgent.csproj"), []byte(csproj), 0600)
if err != nil {
t.Fatal(err)
}

output, _ := captureStdout(t, func() error {
validateDotnetRuntimeVsCsproj(dir, "dotnet_9")
return nil
})
if !strings.Contains(output, "ERROR") {
t.Errorf("expected ERROR in output, got: %s", output)
}
}

func TestValidateDotnetRuntimeVsCsproj_Match(t *testing.T) {
dir := t.TempDir()
csproj := `<Project><PropertyGroup><TargetFramework>net9.0</TargetFramework></PropertyGroup></Project>`
err := os.WriteFile(filepath.Join(dir, "MyAgent.csproj"), []byte(csproj), 0600)
if err != nil {
t.Fatal(err)
}

output, _ := captureStdout(t, func() error {
validateDotnetRuntimeVsCsproj(dir, "dotnet_9")
return nil
})
if !strings.Contains(output, "OK") {
t.Errorf("expected OK in output, got: %s", output)
}
}

func TestValidateDotnetRuntimeVsCsproj_HigherRuntime(t *testing.T) {
dir := t.TempDir()
csproj := `<Project><PropertyGroup><TargetFramework>net9.0</TargetFramework></PropertyGroup></Project>`
err := os.WriteFile(filepath.Join(dir, "MyAgent.csproj"), []byte(csproj), 0600)
if err != nil {
t.Fatal(err)
}

// dotnet_10 runtime with net9.0 target — should pass (OK), no error
validateDotnetRuntimeVsCsproj(dir, "dotnet_10")
}

func TestValidateDotnetRuntimeVsCsproj_NoCsproj(t *testing.T) {
dir := t.TempDir()
// No .csproj file — should skip silently without panic
validateDotnetRuntimeVsCsproj(dir, "dotnet_9")
}

func TestValidateDotnetRuntimeVsCsproj_UnreadableDir(t *testing.T) {
// Non-existent directory — should print ERROR without panic
validateDotnetRuntimeVsCsproj("/nonexistent/path/xyz", "dotnet_9")
}

func TestValidateDotnetRuntimeVsCsproj_PythonSkipped(t *testing.T) {
// Python runtime should be skipped entirely
validateDotnetRuntimeVsCsproj("/any/path", "python_3_12")
}

func TestValidatePostInit_NilCodeConfig(t *testing.T) {
// Should not panic with nil codeConfig
validatePostInit("/any/path", nil)
}

func TestValidatePostInit_DotnetTriggersValidation(t *testing.T) {
dir := t.TempDir()
csproj := `<Project><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>`
err := os.WriteFile(filepath.Join(dir, "Test.csproj"), []byte(csproj), 0600)
if err != nil {
t.Fatal(err)
}

// Should trigger validation and print ERROR (non-blocking)
codeConfig := &agent_yaml.CodeConfiguration{
Runtime: "dotnet_9",
EntryPoint: "Test.dll",
}
validatePostInit(dir, codeConfig)
}

func TestValidatePostInit_PythonSkipsValidation(t *testing.T) {
// Python code config should not trigger dotnet validation
codeConfig := &agent_yaml.CodeConfiguration{
Runtime: "python_3_12",
EntryPoint: "main.py",
}
validatePostInit("/any/path", codeConfig)
}
Loading