Skip to content

Commit e4cab71

Browse files
authored
adding project-endpoint to init command (#6596)
1 parent 042c074 commit e4cab71

File tree

4 files changed

+174
-10
lines changed

4 files changed

+174
-10
lines changed

cli/azd/extensions/azure.ai.finetune/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Release History
22

3+
## 0.0.12-preview (2026-01-23)
4+
5+
- Add Project-endpoint parameter to init command
6+
37
## 0.0.11-preview (2026-01-22)
48

59
- Add metadata capability

cli/azd/extensions/azure.ai.finetune/extension.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ namespace: ai.finetuning
33
displayName: Foundry Fine Tuning (Preview)
44
description: Extension for Foundry Fine Tuning. (Preview)
55
usage: azd ai finetuning <command> [options]
6-
version: 0.0.11-preview
6+
version: 0.0.12-preview
77
language: go
88
capabilities:
99
- custom-commands

cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go

Lines changed: 168 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/azure/azure-dev/cli/azd/pkg/exec"
2323
"github.com/azure/azure-dev/cli/azd/pkg/input"
2424
"github.com/azure/azure-dev/cli/azd/pkg/tools/github"
25+
"github.com/azure/azure-dev/cli/azd/pkg/ux"
2526
"github.com/fatih/color"
2627
"github.com/spf13/cobra"
2728

@@ -32,6 +33,8 @@ type initFlags struct {
3233
rootFlagsDefinition
3334
template string
3435
projectResourceId string
36+
subscriptionId string
37+
projectEndpoint string
3538
jobId string
3639
src string
3740
env string
@@ -137,16 +140,22 @@ func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command {
137140
cmd.Flags().StringVarP(&flags.template, "template", "t", "",
138141
"URL or path to a fine-tune job template")
139142

140-
cmd.Flags().StringVarP(&flags.projectResourceId, "project", "p", "",
141-
"Existing Microsoft Foundry Project Id to initialize your azd environment with")
143+
cmd.Flags().StringVarP(&flags.projectResourceId, "project-resource-id", "p", "",
144+
"ARM resource ID of the Microsoft Foundry Project (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{account}/projects/{project})")
142145

143-
cmd.Flags().StringVarP(&flags.src, "source", "s", "",
146+
cmd.Flags().StringVarP(&flags.subscriptionId, "subscription", "s", "",
147+
"Azure subscription ID")
148+
149+
cmd.Flags().StringVarP(&flags.projectEndpoint, "project-endpoint", "e", "",
150+
"Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)")
151+
152+
cmd.Flags().StringVarP(&flags.src, "working-directory", "w", "",
144153
"Local path for project output")
145154

146155
cmd.Flags().StringVarP(&flags.jobId, "from-job", "j", "",
147156
"Clone configuration from an existing job ID")
148157

149-
cmd.Flags().StringVarP(&flags.env, "environment", "e", "", "The name of the azd environment to use.")
158+
cmd.Flags().StringVarP(&flags.env, "environment", "n", "", "The name of the azd environment to use.")
150159

151160
return cmd
152161
}
@@ -181,6 +190,98 @@ func extractProjectDetails(projectResourceId string) (*FoundryProject, error) {
181190
}, nil
182191
}
183192

193+
// parseProjectEndpoint extracts account name and project name from an endpoint URL
194+
// Example: https://account-name.services.ai.azure.com/api/projects/project-name
195+
func parseProjectEndpoint(endpoint string) (accountName string, projectName string, err error) {
196+
parsedURL, err := url.Parse(endpoint)
197+
if err != nil {
198+
return "", "", fmt.Errorf("failed to parse endpoint URL: %w", err)
199+
}
200+
201+
// Extract account name from hostname (e.g., "account-name.services.ai.azure.com")
202+
hostname := parsedURL.Hostname()
203+
hostParts := strings.Split(hostname, ".")
204+
if len(hostParts) < 1 || hostParts[0] == "" {
205+
return "", "", fmt.Errorf("invalid endpoint URL: cannot extract account name from hostname")
206+
}
207+
accountName = hostParts[0]
208+
209+
// Extract project name from path (e.g., "/api/projects/project-name")
210+
pathParts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/")
211+
// Expected path: api/projects/{project-name}
212+
if len(pathParts) >= 3 && pathParts[0] == "api" && pathParts[1] == "projects" {
213+
projectName = pathParts[2]
214+
} else {
215+
return "", "", fmt.Errorf("invalid endpoint URL: cannot extract project name from path. Expected format: /api/projects/{project-name}")
216+
}
217+
218+
return accountName, projectName, nil
219+
}
220+
221+
// findProjectByEndpoint searches for a Foundry project matching the endpoint URL
222+
func findProjectByEndpoint(
223+
ctx context.Context,
224+
subscriptionId string,
225+
accountName string,
226+
projectName string,
227+
credential azcore.TokenCredential,
228+
) (*FoundryProject, error) {
229+
// Create Cognitive Services Accounts client to search for the account
230+
accountsClient, err := armcognitiveservices.NewAccountsClient(subscriptionId, credential, nil)
231+
if err != nil {
232+
return nil, fmt.Errorf("failed to create Cognitive Services Accounts client: %w", err)
233+
}
234+
235+
// List all accounts in the subscription and find the matching one
236+
pager := accountsClient.NewListPager(nil)
237+
var foundAccount *armcognitiveservices.Account
238+
for pager.More() {
239+
page, err := pager.NextPage(ctx)
240+
if err != nil {
241+
return nil, fmt.Errorf("failed to list Cognitive Services accounts: %w", err)
242+
}
243+
for _, account := range page.Value {
244+
if account.Name != nil && strings.EqualFold(*account.Name, accountName) {
245+
foundAccount = account
246+
break
247+
}
248+
}
249+
if foundAccount != nil {
250+
break
251+
}
252+
}
253+
254+
if foundAccount == nil {
255+
return nil, fmt.Errorf("could not find Cognitive Services account '%s' in subscription '%s'", accountName, subscriptionId)
256+
}
257+
258+
// Parse the account's resource ID to get resource group
259+
accountResourceId, err := arm.ParseResourceID(*foundAccount.ID)
260+
if err != nil {
261+
return nil, fmt.Errorf("failed to parse account resource ID: %w", err)
262+
}
263+
264+
// Create Projects client to verify the project exists
265+
projectsClient, err := armcognitiveservices.NewProjectsClient(subscriptionId, credential, nil)
266+
if err != nil {
267+
return nil, fmt.Errorf("failed to create Cognitive Services Projects client: %w", err)
268+
}
269+
270+
// Get the project to verify it exists and get its details
271+
projectResp, err := projectsClient.Get(ctx, accountResourceId.ResourceGroupName, accountName, projectName, nil)
272+
if err != nil {
273+
return nil, fmt.Errorf("could not find project '%s' under account '%s': %w", projectName, accountName, err)
274+
}
275+
276+
return &FoundryProject{
277+
SubscriptionId: subscriptionId,
278+
ResourceGroupName: accountResourceId.ResourceGroupName,
279+
AiAccountName: accountName,
280+
AiProjectName: projectName,
281+
Location: *projectResp.Location,
282+
}, nil
283+
}
284+
184285
func getExistingEnvironment(ctx context.Context, name *string, azdClient *azdext.AzdClient) (*azdext.Environment, error) {
185286
var env *azdext.Environment
186287
if name == nil || *name == "" {
@@ -205,8 +306,67 @@ func getExistingEnvironment(ctx context.Context, name *string, azdClient *azdext
205306
func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.Environment, error) {
206307
var foundryProject *FoundryProject
207308

208-
// Parse the Microsoft Foundry project resource ID if provided & Fetch Tenant Id and Location using parsed information
209-
if flags.projectResourceId != "" {
309+
// Handle project endpoint URL - extract account/project names and find the ARM resource
310+
if flags.projectEndpoint != "" {
311+
accountName, projectName, err := parseProjectEndpoint(flags.projectEndpoint)
312+
if err != nil {
313+
return nil, fmt.Errorf("failed to parse project endpoint: %w", err)
314+
}
315+
316+
fmt.Printf("Parsed endpoint - Account: %s, Project: %s\n", accountName, projectName)
317+
318+
// Get subscription ID - either from flag or prompt
319+
subscriptionId := flags.subscriptionId
320+
var tenantId string
321+
322+
if subscriptionId == "" {
323+
fmt.Println("Subscription ID is required to find the project. Let's select one.")
324+
subscriptionResponse, err := azdClient.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{})
325+
if err != nil {
326+
return nil, fmt.Errorf("failed to prompt for subscription: %w", err)
327+
}
328+
subscriptionId = subscriptionResponse.Subscription.Id
329+
tenantId = subscriptionResponse.Subscription.TenantId
330+
} else {
331+
// Get tenant ID from subscription
332+
tenantResponse, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{
333+
SubscriptionId: subscriptionId,
334+
})
335+
if err != nil {
336+
return nil, fmt.Errorf("failed to get tenant ID: %w", err)
337+
}
338+
tenantId = tenantResponse.TenantId
339+
}
340+
341+
// Create credential
342+
credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{
343+
TenantID: tenantId,
344+
AdditionallyAllowedTenants: []string{"*"},
345+
})
346+
if err != nil {
347+
return nil, fmt.Errorf("failed to create Azure credential: %w", err)
348+
}
349+
350+
// Find the project by searching the subscription
351+
spinner := ux.NewSpinner(&ux.SpinnerOptions{
352+
Text: fmt.Sprintf("Searching for project in subscription %s...", subscriptionId),
353+
})
354+
if err := spinner.Start(ctx); err != nil {
355+
fmt.Printf("failed to start spinner: %v\n", err)
356+
}
357+
358+
foundryProject, err = findProjectByEndpoint(ctx, subscriptionId, accountName, projectName, credential)
359+
_ = spinner.Stop(ctx)
360+
if err != nil {
361+
return nil, fmt.Errorf("failed to find project from endpoint: %w", err)
362+
}
363+
foundryProject.TenantId = tenantId
364+
365+
fmt.Printf("Found project - Resource Group: %s, Account: %s, Project: %s\n",
366+
foundryProject.ResourceGroupName, foundryProject.AiAccountName, foundryProject.AiProjectName)
367+
368+
} else if flags.projectResourceId != "" {
369+
// Parse the Microsoft Foundry project resource ID if provided & Fetch Tenant Id and Location using parsed information
210370
var err error
211371
foundryProject, err = extractProjectDetails(flags.projectResourceId)
212372
if err != nil {
@@ -258,7 +418,7 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.
258418
envArgs = append(envArgs, flags.env)
259419
}
260420

261-
if flags.projectResourceId != "" {
421+
if foundryProject != nil {
262422
envArgs = append(envArgs, "--subscription", foundryProject.SubscriptionId)
263423
envArgs = append(envArgs, "--location", foundryProject.Location)
264424
}
@@ -287,7 +447,7 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.
287447
}
288448

289449
// Set TenantId, SubscriptionId, ResourceGroupName, AiAccountName, and Location in the environment
290-
if flags.projectResourceId != "" {
450+
if foundryProject != nil {
291451

292452
_, err := azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{
293453
EnvName: existingEnv.Name,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.0.11-preview
1+
0.0.12-preview

0 commit comments

Comments
 (0)