diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index d48df6a0a1f..349562b845b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -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!") } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index b7146fcc205..abbe99e7a44 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -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, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate.go new file mode 100644 index 00000000000..311c8027283 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate.go @@ -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 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. "net10.0" -> 10 +// Returns 0 if not found or not parsable. +func extractTargetFrameworkVersion(csprojContent string) int { + re := regexp.MustCompile(`net(\d+)\.\d+[^<]*`) + matches := re.FindStringSubmatch(csprojContent) + if len(matches) < 2 { + return 0 + } + version, err := strconv.Atoi(matches[1]) + if err != nil { + return 0 + } + return version +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate_test.go new file mode 100644 index 00000000000..6b0f0fe3a71 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate_test.go @@ -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", `net8.0`, 8}, + {"net9.0", `net9.0`, 9}, + {"net10.0", `net10.0`, 10}, + {"no target framework", ``, 0}, + {"empty", "", 0}, + {"netstandard", `netstandard2.0`, 0}, + {"netcoreapp3.1", `netcoreapp3.1`, 0}, + {"net8.0-windows", `net8.0-windows`, 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 := `net10.0` + 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 := `net9.0` + 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 := `net9.0` + 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 := `net10.0` + 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) +}