Skip to content

Commit b26ed3e

Browse files
Copilotspboyerweikanglimwbreza
authored
Support azure.yml in addition to azure.yaml (#6285)
* Initial plan * Support azure.yml in addition to azure.yaml - Updated Go code to search for both azure.yaml and azure.yml files - azure.yaml takes precedence when both files exist - Updated VS Code extension context menus to show for both file extensions - Updated glob patterns and validation schemas for both file names - Updated error messages to mention both file extensions Co-authored-by: spboyer <[email protected]> * Address code review feedback - Add clarifying comment to ProjectPath() method explaining its dual behavior - Fix VS Code glob pattern to be case-insensitive using character classes - Pattern now correctly matches azure.yaml, azure.yml, AZURE.YAML, Azure.Yml, etc. Co-authored-by: spboyer <[email protected]> * Final verification and security review completed Co-authored-by: spboyer <[email protected]> * Revert go.mod and go.sum to original state These files are manifest/lockfiles managed by the Go toolchain and should not be manually modified. Restored them to their original state before my changes. Co-authored-by: spboyer <[email protected]> * Handle unexpected errors from os.Stat in project file search Added proper error handling to return errors from os.Stat that are not os.ErrNotExist. This ensures unexpected file system errors (e.g., permission issues) are properly propagated instead of being silently ignored. Co-authored-by: weikanglim <[email protected]> * Add comprehensive unit tests for azdcontext package Added unit tests to validate: - Finding azure.yaml files - Finding azure.yml files - Precedence when both files exist (azure.yaml takes priority) - Walking up from subdirectories - Handling missing project files (returns ErrNoProject) - Handling directories with same name as project files - ProjectPath() behavior with and without found files - Helper methods (SetProjectDirectory, EnvironmentDirectory, etc.) - ProjectFileNames order and contents All tests pass with 0 linting issues. Co-authored-by: wbreza <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: spboyer <[email protected]> Co-authored-by: weikanglim <[email protected]> Co-authored-by: wbreza <[email protected]>
1 parent e52f1b4 commit b26ed3e

File tree

4 files changed

+275
-30
lines changed

4 files changed

+275
-30
lines changed

cli/azd/pkg/environment/azdcontext/azdcontext.go

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ const DotEnvFileName = ".env"
2020
const ConfigFileName = "config.json"
2121
const ConfigFileVersion = 1
2222

23+
// ProjectFileNames lists all valid project file names, in order of preference
24+
var ProjectFileNames = []string{"azure.yaml", "azure.yml"}
25+
2326
type AzdContext struct {
2427
projectDirectory string
28+
projectFilePath string
2529
}
2630

2731
func (c *AzdContext) ProjectDirectory() string {
@@ -32,7 +36,13 @@ func (c *AzdContext) SetProjectDirectory(dir string) {
3236
c.projectDirectory = dir
3337
}
3438

39+
// ProjectPath returns the path to the project file. If the context was created by searching
40+
// for a project file, returns the actual file that was found. Otherwise, returns the default
41+
// project file name joined with the project directory (useful when creating new projects).
3542
func (c *AzdContext) ProjectPath() string {
43+
if c.projectFilePath != "" {
44+
return c.projectFilePath
45+
}
3646
return filepath.Join(c.ProjectDirectory(), ProjectFileName)
3747
}
3848

@@ -129,26 +139,41 @@ func NewAzdContextFromWd(wd string) (*AzdContext, error) {
129139
return nil, fmt.Errorf("resolving path: %w", err)
130140
}
131141

142+
var foundProjectFilePath string
132143
for {
133-
projectFilePath := filepath.Join(searchDir, ProjectFileName)
134-
stat, err := os.Stat(projectFilePath)
135-
if os.IsNotExist(err) || (err == nil && stat.IsDir()) {
136-
parent := filepath.Dir(searchDir)
137-
if parent == searchDir {
138-
return nil, ErrNoProject
144+
// Try all valid project file names in order of preference
145+
for _, fileName := range ProjectFileNames {
146+
projectFilePath := filepath.Join(searchDir, fileName)
147+
stat, err := os.Stat(projectFilePath)
148+
if os.IsNotExist(err) || (err == nil && stat.IsDir()) {
149+
// File doesn't exist or is a directory, try next file name
150+
continue
151+
} else if err == nil {
152+
// We found a valid project file
153+
foundProjectFilePath = projectFilePath
154+
break
155+
} else {
156+
// An unexpected error occurred
157+
return nil, fmt.Errorf("searching for project file: %w", err)
139158
}
140-
searchDir = parent
141-
} else if err == nil {
142-
// We found our azure.yaml file, and searchDir is the directory
143-
// that contains it.
159+
}
160+
161+
if foundProjectFilePath != "" {
162+
// We found our project file, and searchDir is the directory that contains it.
144163
break
145-
} else {
146-
return nil, fmt.Errorf("searching for project file: %w", err)
147164
}
165+
166+
// No project file found in this directory, move up to parent
167+
parent := filepath.Dir(searchDir)
168+
if parent == searchDir {
169+
return nil, ErrNoProject
170+
}
171+
searchDir = parent
148172
}
149173

150174
return &AzdContext{
151175
projectDirectory: searchDir,
176+
projectFilePath: foundProjectFilePath,
152177
}, nil
153178
}
154179

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package azdcontext
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestNewAzdContextFromWd_WithAzureYaml(t *testing.T) {
15+
tempDir := t.TempDir()
16+
17+
// Create azure.yaml file
18+
azureYamlPath := filepath.Join(tempDir, "azure.yaml")
19+
err := os.WriteFile(azureYamlPath, []byte("name: test\n"), 0600)
20+
require.NoError(t, err)
21+
22+
// Test from the directory containing azure.yaml
23+
ctx, err := NewAzdContextFromWd(tempDir)
24+
require.NoError(t, err)
25+
require.NotNil(t, ctx)
26+
require.Equal(t, tempDir, ctx.ProjectDirectory())
27+
require.Equal(t, azureYamlPath, ctx.ProjectPath())
28+
}
29+
30+
func TestNewAzdContextFromWd_WithAzureYml(t *testing.T) {
31+
tempDir := t.TempDir()
32+
33+
// Create azure.yml file
34+
azureYmlPath := filepath.Join(tempDir, "azure.yml")
35+
err := os.WriteFile(azureYmlPath, []byte("name: test\n"), 0600)
36+
require.NoError(t, err)
37+
38+
// Test from the directory containing azure.yml
39+
ctx, err := NewAzdContextFromWd(tempDir)
40+
require.NoError(t, err)
41+
require.NotNil(t, ctx)
42+
require.Equal(t, tempDir, ctx.ProjectDirectory())
43+
require.Equal(t, azureYmlPath, ctx.ProjectPath())
44+
}
45+
46+
func TestNewAzdContextFromWd_BothFilesExist_YamlTakesPrecedence(t *testing.T) {
47+
tempDir := t.TempDir()
48+
49+
// Create both azure.yaml and azure.yml
50+
azureYamlPath := filepath.Join(tempDir, "azure.yaml")
51+
azureYmlPath := filepath.Join(tempDir, "azure.yml")
52+
err := os.WriteFile(azureYamlPath, []byte("name: yaml\n"), 0600)
53+
require.NoError(t, err)
54+
err = os.WriteFile(azureYmlPath, []byte("name: yml\n"), 0600)
55+
require.NoError(t, err)
56+
57+
// Test that azure.yaml takes precedence
58+
ctx, err := NewAzdContextFromWd(tempDir)
59+
require.NoError(t, err)
60+
require.NotNil(t, ctx)
61+
require.Equal(t, tempDir, ctx.ProjectDirectory())
62+
require.Equal(t, azureYamlPath, ctx.ProjectPath(), "azure.yaml should take precedence over azure.yml")
63+
}
64+
65+
func TestNewAzdContextFromWd_FromSubdirectory(t *testing.T) {
66+
tempDir := t.TempDir()
67+
subDir := filepath.Join(tempDir, "src", "api")
68+
err := os.MkdirAll(subDir, 0755)
69+
require.NoError(t, err)
70+
71+
// Create azure.yml in the root
72+
azureYmlPath := filepath.Join(tempDir, "azure.yml")
73+
err = os.WriteFile(azureYmlPath, []byte("name: test\n"), 0600)
74+
require.NoError(t, err)
75+
76+
// Test from subdirectory - should walk up and find the file
77+
ctx, err := NewAzdContextFromWd(subDir)
78+
require.NoError(t, err)
79+
require.NotNil(t, ctx)
80+
require.Equal(t, tempDir, ctx.ProjectDirectory())
81+
require.Equal(t, azureYmlPath, ctx.ProjectPath())
82+
}
83+
84+
func TestNewAzdContextFromWd_NoProjectFile(t *testing.T) {
85+
tempDir := t.TempDir()
86+
87+
// No project file exists
88+
ctx, err := NewAzdContextFromWd(tempDir)
89+
require.Error(t, err)
90+
require.Nil(t, ctx)
91+
require.ErrorIs(t, err, ErrNoProject)
92+
}
93+
94+
func TestNewAzdContextFromWd_DirectoryWithSameName(t *testing.T) {
95+
tempDir := t.TempDir()
96+
97+
// Create a directory named azure.yaml (edge case)
98+
azureYamlDir := filepath.Join(tempDir, "azure.yaml")
99+
err := os.Mkdir(azureYamlDir, 0755)
100+
require.NoError(t, err)
101+
102+
// Create actual azure.yml file
103+
azureYmlPath := filepath.Join(tempDir, "azure.yml")
104+
err = os.WriteFile(azureYmlPath, []byte("name: test\n"), 0600)
105+
require.NoError(t, err)
106+
107+
// Should find azure.yml and skip the directory
108+
ctx, err := NewAzdContextFromWd(tempDir)
109+
require.NoError(t, err)
110+
require.NotNil(t, ctx)
111+
require.Equal(t, tempDir, ctx.ProjectDirectory())
112+
require.Equal(t, azureYmlPath, ctx.ProjectPath())
113+
}
114+
115+
func TestNewAzdContextFromWd_InvalidPath(t *testing.T) {
116+
// Test with a path that doesn't exist
117+
ctx, err := NewAzdContextFromWd("/this/path/does/not/exist/at/all")
118+
require.Error(t, err)
119+
require.Nil(t, ctx)
120+
}
121+
122+
func TestProjectPath_WithFoundFile(t *testing.T) {
123+
tempDir := t.TempDir()
124+
azureYmlPath := filepath.Join(tempDir, "azure.yml")
125+
err := os.WriteFile(azureYmlPath, []byte("name: test\n"), 0600)
126+
require.NoError(t, err)
127+
128+
ctx, err := NewAzdContextFromWd(tempDir)
129+
require.NoError(t, err)
130+
131+
// ProjectPath should return the actual found file
132+
require.Equal(t, azureYmlPath, ctx.ProjectPath())
133+
}
134+
135+
func TestProjectPath_WithoutFoundFile(t *testing.T) {
136+
// Create context directly without searching
137+
ctx := NewAzdContextWithDirectory("/some/path")
138+
139+
// ProjectPath should return default azure.yaml
140+
expected := filepath.Join("/some/path", "azure.yaml")
141+
require.Equal(t, expected, ctx.ProjectPath())
142+
}
143+
144+
func TestNewAzdContextWithDirectory(t *testing.T) {
145+
testDir := "/test/directory"
146+
ctx := NewAzdContextWithDirectory(testDir)
147+
148+
require.NotNil(t, ctx)
149+
require.Equal(t, testDir, ctx.ProjectDirectory())
150+
require.Equal(t, filepath.Join(testDir, "azure.yaml"), ctx.ProjectPath())
151+
}
152+
153+
func TestSetProjectDirectory(t *testing.T) {
154+
ctx := NewAzdContextWithDirectory("/original/path")
155+
require.Equal(t, "/original/path", ctx.ProjectDirectory())
156+
157+
ctx.SetProjectDirectory("/new/path")
158+
require.Equal(t, "/new/path", ctx.ProjectDirectory())
159+
}
160+
161+
func TestEnvironmentDirectory(t *testing.T) {
162+
ctx := NewAzdContextWithDirectory("/test/path")
163+
expected := filepath.Join("/test/path", ".azure")
164+
require.Equal(t, expected, ctx.EnvironmentDirectory())
165+
}
166+
167+
func TestEnvironmentRoot(t *testing.T) {
168+
ctx := NewAzdContextWithDirectory("/test/path")
169+
expected := filepath.Join("/test/path", ".azure", "env1")
170+
require.Equal(t, expected, ctx.EnvironmentRoot("env1"))
171+
}
172+
173+
func TestGetEnvironmentWorkDirectory(t *testing.T) {
174+
ctx := NewAzdContextWithDirectory("/test/path")
175+
expected := filepath.Join("/test/path", ".azure", "env1", "wd")
176+
require.Equal(t, expected, ctx.GetEnvironmentWorkDirectory("env1"))
177+
}
178+
179+
func TestProjectFileNames_Order(t *testing.T) {
180+
// Verify the order of preference
181+
require.Len(t, ProjectFileNames, 2)
182+
require.Equal(t, "azure.yaml", ProjectFileNames[0], "azure.yaml should be first (highest priority)")
183+
require.Equal(t, "azure.yml", ProjectFileNames[1], "azure.yml should be second")
184+
}
185+
186+
func TestProjectName(t *testing.T) {
187+
tests := []struct {
188+
name string
189+
inputDir string
190+
expected string
191+
}{
192+
{
193+
name: "Simple directory name",
194+
inputDir: "/home/user/my-project",
195+
expected: "my-project",
196+
},
197+
{
198+
name: "Directory with special characters",
199+
inputDir: "/home/user/My_Project-123",
200+
expected: "my-project-123",
201+
},
202+
{
203+
name: "Root directory",
204+
inputDir: "/",
205+
expected: "",
206+
},
207+
}
208+
209+
for _, tt := range tests {
210+
t.Run(tt.name, func(t *testing.T) {
211+
result := ProjectName(tt.inputDir)
212+
require.Equal(t, tt.expected, result)
213+
})
214+
}
215+
}

ext/vscode/package.json

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@
194194
"explorer/context": [
195195
{
196196
"submenu": "azure-dev.explorer.submenu",
197-
"when": "resourceFilename =~ /(azure.yaml|pom.xml)/i",
197+
"when": "resourceFilename =~ /(azure.ya?ml|pom.xml)/i",
198198
"group": "azure-dev"
199199
}
200200
],
@@ -205,62 +205,62 @@
205205
"group": "10provision@10"
206206
},
207207
{
208-
"when": "resourceFilename =~ /azure.yaml/i",
208+
"when": "resourceFilename =~ /azure.ya?ml/i",
209209
"command": "azure-dev.commands.cli.provision",
210210
"group": "10provision@10"
211211
},
212212
{
213-
"when": "resourceFilename =~ /azure.yaml/i",
213+
"when": "resourceFilename =~ /azure.ya?ml/i",
214214
"command": "azure-dev.commands.cli.deploy",
215215
"group": "10provision@20"
216216
},
217217
{
218-
"when": "resourceFilename =~ /azure.yaml/i",
218+
"when": "resourceFilename =~ /azure.ya?ml/i",
219219
"command": "azure-dev.commands.cli.up",
220220
"group": "10provision@30"
221221
},
222222
{
223-
"when": "resourceFilename =~ /azure.yaml/i",
223+
"when": "resourceFilename =~ /azure.ya?ml/i",
224224
"command": "azure-dev.commands.cli.down",
225225
"group": "10provision@40"
226226
},
227227
{
228-
"when": "resourceFilename =~ /azure.yaml/i",
228+
"when": "resourceFilename =~ /azure.ya?ml/i",
229229
"command": "azure-dev.commands.cli.env-new",
230230
"group": "20env@10"
231231
},
232232
{
233-
"when": "resourceFilename =~ /azure.yaml/i",
233+
"when": "resourceFilename =~ /azure.ya?ml/i",
234234
"command": "azure-dev.commands.cli.env-select",
235235
"group": "20env@20"
236236
},
237237
{
238-
"when": "resourceFilename =~ /azure.yaml/i",
238+
"when": "resourceFilename =~ /azure.ya?ml/i",
239239
"command": "azure-dev.commands.cli.env-refresh",
240240
"group": "20env@30"
241241
},
242242
{
243-
"when": "resourceFilename =~ /azure.yaml/i",
243+
"when": "resourceFilename =~ /azure.ya?ml/i",
244244
"command": "azure-dev.commands.cli.env-list",
245245
"group": "20env@40"
246246
},
247247
{
248-
"when": "resourceFilename =~ /azure.yaml/i",
248+
"when": "resourceFilename =~ /azure.ya?ml/i",
249249
"command": "azure-dev.commands.cli.restore",
250250
"group": "30develop@10"
251251
},
252252
{
253-
"when": "resourceFilename =~ /azure.yaml/i",
253+
"when": "resourceFilename =~ /azure.ya?ml/i",
254254
"command": "azure-dev.commands.cli.package",
255255
"group": "30develop@20"
256256
},
257257
{
258-
"when": "resourceFilename =~ /azure.yaml/i",
258+
"when": "resourceFilename =~ /azure.ya?ml/i",
259259
"command": "azure-dev.commands.cli.pipeline-config",
260260
"group": "30develop@30"
261261
},
262262
{
263-
"when": "resourceFilename =~ /azure.yaml/i",
263+
"when": "resourceFilename =~ /azure.ya?ml/i",
264264
"command": "azure-dev.commands.cli.monitor",
265265
"group": "40monitor@10"
266266
}
@@ -500,6 +500,10 @@
500500
{
501501
"fileMatch": "azure.yaml",
502502
"url": "https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json"
503+
},
504+
{
505+
"fileMatch": "azure.yml",
506+
"url": "https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json"
503507
}
504508
]
505509
},

0 commit comments

Comments
 (0)