Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/ux"
)
Expand All @@ -41,6 +42,8 @@ type FoundryDeploymentInfo struct {
SkuCapacity int
}

const foundryProjectResourceType = "Microsoft.CognitiveServices/accounts/projects"

// setEnvValue sets a single environment variable in the azd environment.
func setEnvValue(ctx context.Context, azdClient *azdext.AzdClient, envName, key, value string) error {
_, err := azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{
Expand Down Expand Up @@ -104,99 +107,108 @@ func extractResourceGroup(resourceId string) string {
return ""
}

func foundryProjectInfoFromResource(resource *armresources.GenericResourceExpanded) (*FoundryProjectInfo, bool) {
if resource == nil || resource.ID == nil || *resource.ID == "" {
return nil, false
}

project, err := extractProjectDetails(*resource.ID)
if err != nil {
return nil, false
}

if resource.Location != nil {
project.Location = *resource.Location
}

return project, true
}

func updateFoundryProjectInfo(project *FoundryProjectInfo, resource *armcognitiveservices.Project) {
if project == nil || resource == nil {
return
}

if resource.ID != nil && *resource.ID != "" {
project.ResourceId = *resource.ID
}

if resource.Name != nil && *resource.Name != "" {
if idx := strings.LastIndex(*resource.Name, "/"); idx != -1 {
project.ProjectName = (*resource.Name)[idx+1:]
} else {
project.ProjectName = *resource.Name
}
}

if resource.Location != nil {
project.Location = *resource.Location
}
}

// listFoundryProjects enumerates all Foundry projects in a subscription by listing
// CognitiveServices accounts and their projects.
// subscription resources filtered to Foundry projects.
func listFoundryProjects(
ctx context.Context,
credential azcore.TokenCredential,
subscriptionId string,
) ([]FoundryProjectInfo, error) {
accountsClient, err := armcognitiveservices.NewAccountsClient(subscriptionId, credential, azure.NewArmClientOptions())
resourcesClient, err := armresources.NewClient(subscriptionId, credential, azure.NewArmClientOptions())
if err != nil {
return nil, fmt.Errorf("failed to create accounts client: %w", err)
}

projectsClient, err := armcognitiveservices.NewProjectsClient(subscriptionId, credential, azure.NewArmClientOptions())
if err != nil {
return nil, fmt.Errorf("failed to create projects client: %w", err)
return nil, fmt.Errorf("failed to create resources client: %w", err)
}

var results []FoundryProjectInfo

accountPager := accountsClient.NewListPager(nil)
for accountPager.More() {
page, err := accountPager.NextPage(ctx)
pager := resourcesClient.NewListPager(&armresources.ClientListOptions{
Filter: new(fmt.Sprintf("resourceType eq '%s'", foundryProjectResourceType)),
})
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list accounts: %w", err)
return nil, fmt.Errorf("failed to list Foundry projects: %w", err)
}

for _, account := range page.Value {
if account.Kind == nil {
continue
}
kind := strings.ToLower(*account.Kind)
if kind != "aiservices" && kind != "openai" {
continue
}

accountId := ""
if account.ID != nil {
accountId = *account.ID
}
rgName := extractResourceGroup(accountId)
if rgName == "" {
continue
}
accountName := ""
if account.Name != nil {
accountName = *account.Name
}
accountLocation := ""
if account.Location != nil {
accountLocation = *account.Location
}

projectPager := projectsClient.NewListPager(rgName, accountName, nil)
for projectPager.More() {
projectPage, err := projectPager.NextPage(ctx)
if err != nil {
// Skip accounts we can't list projects for (permissions, etc.)
break
}
for _, proj := range projectPage.Value {
projName := ""
if proj.Name != nil {
fullName := *proj.Name
if idx := strings.LastIndex(fullName, "/"); idx != -1 {
projName = fullName[idx+1:]
} else {
projName = fullName
}
}
projLocation := accountLocation
if proj.Location != nil {
projLocation = *proj.Location
}
resourceId := fmt.Sprintf(
"/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s",
subscriptionId, rgName, accountName, projName)

results = append(results, FoundryProjectInfo{
SubscriptionId: subscriptionId,
ResourceGroupName: rgName,
AccountName: accountName,
ProjectName: projName,
Location: projLocation,
ResourceId: resourceId,
})
}
for _, resource := range page.Value {
if project, ok := foundryProjectInfoFromResource(resource); ok {
results = append(results, *project)
}
}
}

return results, nil
}

func getFoundryProject(
ctx context.Context,
credential azcore.TokenCredential,
subscriptionId string,
projectResourceId string,
) (*FoundryProjectInfo, error) {
project, err := extractProjectDetails(projectResourceId)
if err != nil {
return nil, err
}

if !strings.EqualFold(project.SubscriptionId, subscriptionId) {
return nil, fmt.Errorf("provided project resource ID does not match the selected subscription")
}

projectsClient, err := armcognitiveservices.NewProjectsClient(project.SubscriptionId, credential, azure.NewArmClientOptions())
if err != nil {
return nil, fmt.Errorf("failed to create projects client: %w", err)
}

response, err := projectsClient.Get(ctx, project.ResourceGroupName, project.AccountName, project.ProjectName, nil)
if err != nil {
return nil, fmt.Errorf("failed to get Foundry project: %w", err)
}

updateFoundryProjectInfo(project, &response.Project)

return project, nil
}

// listProjectDeployments lists all model deployments in a Foundry account.
func listProjectDeployments(
ctx context.Context,
Expand Down Expand Up @@ -884,11 +896,26 @@ func selectFoundryProject(
return nil, fmt.Errorf("failed to start spinner: %w", err)
}

projects, err := listFoundryProjects(ctx, credential, subscriptionId)
var (
projects []FoundryProjectInfo
err error
)
if projectResourceId != "" {
var project *FoundryProjectInfo
project, err = getFoundryProject(ctx, credential, subscriptionId, projectResourceId)
if err == nil {
projects = append(projects, *project)
}
} else {
projects, err = listFoundryProjects(ctx, credential, subscriptionId)
}
if stopErr := spinner.Stop(ctx); stopErr != nil {
return nil, stopErr
}
if err != nil {
if projectResourceId != "" {
return nil, fmt.Errorf("failed to get Foundry project: %w", err)
}
return nil, fmt.Errorf("failed to list Foundry projects: %w", err)
}

Expand All @@ -899,16 +926,7 @@ func selectFoundryProject(
var selectedIdx int32 = -1

if projectResourceId != "" {
// Match from flag
for i, p := range projects {
if strings.EqualFold(p.ResourceId, projectResourceId) {
selectedIdx = int32(i)
break
}
}
if selectedIdx == -1 {
return nil, fmt.Errorf("provided project resource ID does not match any Foundry projects in the subscription")
}
selectedIdx = 0
} else {
// Sort projects alphabetically by account/project name for display
slices.SortFunc(projects, func(a, b FoundryProjectInfo) int {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package cmd
import (
"testing"

armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -105,3 +107,97 @@ func TestFoundryProjectInfoResourceIdConstruction(t *testing.T) {

require.Equal(t, originalId, reconstructed)
}

func TestFoundryProjectInfoFromResource(t *testing.T) {
t.Parallel()

resourceId := "/subscriptions/sub-id/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/projects/my-project"

tests := []struct {
name string
resource *armresources.GenericResourceExpanded
want *FoundryProjectInfo
}{
{
name: "maps filtered subscription resource",
resource: &armresources.GenericResourceExpanded{
ID: new(resourceId),
Location: new("eastus"),
},
want: &FoundryProjectInfo{
SubscriptionId: "sub-id",
ResourceGroupName: "my-rg",
AccountName: "my-account",
ProjectName: "my-project",
Location: "eastus",
ResourceId: resourceId,
},
},
{
name: "skips resource without id",
resource: &armresources.GenericResourceExpanded{},
},
{
name: "skips malformed project resource id",
resource: &armresources.GenericResourceExpanded{
ID: new("/subscriptions/sub-id/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account"),
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got, ok := foundryProjectInfoFromResource(tt.resource)
if tt.want == nil {
require.False(t, ok)
require.Nil(t, got)
return
}

require.True(t, ok)
require.Equal(t, tt.want, got)
})
}
}

func TestUpdateFoundryProjectInfo(t *testing.T) {
t.Parallel()

project := &FoundryProjectInfo{
SubscriptionId: "sub-id",
ResourceGroupName: "my-rg",
AccountName: "my-account",
ProjectName: "my-project",
ResourceId: "/subscriptions/sub-id/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/projects/my-project",
}

updateFoundryProjectInfo(project, &armcognitiveservices.Project{
ID: new("/subscriptions/sub-id/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/projects/my-project"),
Name: new("my-account/updated-project"),
Location: new("westus"),
})

require.Equal(t, "updated-project", project.ProjectName)
require.Equal(t, "westus", project.Location)
require.Equal(
t,
"/subscriptions/sub-id/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/projects/my-project",
project.ResourceId,
)
}

func TestGetFoundryProject_SubscriptionMismatch(t *testing.T) {
t.Parallel()

_, err := getFoundryProject(
t.Context(),
nil,
"selected-subscription",
"/subscriptions/other-subscription/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/projects/my-project",
)

require.Error(t, err)
require.Contains(t, err.Error(), "does not match the selected subscription")
}