Skip to content
Open
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 @@ -54,6 +54,8 @@ func newJobSubmitCommand() *cobra.Command {
accountName := envValues[utils.EnvAzureAccountName]
projectName := envValues[utils.EnvAzureProjectName]
tenantID := envValues[utils.EnvAzureTenantID]
subscriptionID := envValues[utils.EnvAzureSubscriptionID]
resourceGroup := envValues[utils.EnvAzureResourceGroup]

if accountName == "" || projectName == "" {
return fmt.Errorf("environment not configured. Run 'azd ai training init' first")
Expand All @@ -73,6 +75,9 @@ func newJobSubmitCommand() *cobra.Command {
return fmt.Errorf("failed to create API client: %w", err)
}

// Set ARM context for control plane calls (e.g. compute resolution)
apiClient.SetARMContext(subscriptionID, resourceGroup, accountName)

// Auto-generate job name if not provided (same pattern as AML SDK)
if jobDef.Name == "" {
jobDef.Name = utils.GenerateJobName()
Expand All @@ -91,7 +96,7 @@ func newJobSubmitCommand() *cobra.Command {

// Resolve references (compute name → ARM ID, local paths → datastore URIs)
resolver := service.NewJobResolver(
service.NewDefaultComputeResolver(),
service.NewDefaultComputeResolver(apiClient),
service.NewDefaultCodeResolver(uploadSvc, projectName),
service.NewDefaultInputResolver(uploadSvc),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,35 @@ package service
import (
"context"
"fmt"

"azure.ai.customtraining/pkg/client"
)

// DefaultComputeResolver is a stub implementation of ComputeResolver.
// Replace with actual ARM API call to resolve compute name to ARM resource ID.
type DefaultComputeResolver struct{}
// DefaultComputeResolver resolves a compute name to a full ARM resource ID
// by calling the ARM control plane API via the Client.
//
// When compute GET moves to the data plane, this resolver can be swapped out
// for a DataPlaneComputeResolver without changing any other code.
type DefaultComputeResolver struct {
client *client.Client
}

func NewDefaultComputeResolver() *DefaultComputeResolver {
return &DefaultComputeResolver{}
// NewDefaultComputeResolver creates a compute resolver that calls the ARM API
// via the given Client. The client must have ARM context set via SetARMContext.
func NewDefaultComputeResolver(apiClient *client.Client) *DefaultComputeResolver {
return &DefaultComputeResolver{
client: apiClient,
}
}

// ResolveCompute calls the ARM API to resolve a compute name to its full ARM resource ID.
// Returns a helpful error message if the user lacks permissions (401/403).
func (r *DefaultComputeResolver) ResolveCompute(ctx context.Context, computeName string) (string, error) {
return "", fmt.Errorf("compute resolution not implemented: provide a full ARM resource ID for compute '%s'", computeName)
result, err := r.client.GetCompute(ctx, computeName)
if err != nil {
return "", err
}

fmt.Printf(" ✓ Compute resolved: %s\n", computeName)
return result.ID, nil
}
49 changes: 49 additions & 0 deletions cli/azd/extensions/azure.ai.customtraining/pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,19 @@ const (
)

// Client is an HTTP client for Azure AI Foundry project APIs.
// It supports both data plane calls (via project endpoint) and ARM
// control plane calls (via SetARMContext).
type Client struct {
baseURL string
subPath string
apiVersion string
credential azcore.TokenCredential
httpClient *http.Client

// ARM context fields (set via SetARMContext)
subscriptionID string
resourceGroup string
accountName string
}

// NewClient creates a new client from a project endpoint URL.
Expand Down Expand Up @@ -74,6 +81,48 @@ func NewClient(projectEndpoint string, credential azcore.TokenCredential) (*Clie
}, nil
}

// SetARMContext configures the client for ARM control plane calls.
// Required before calling ARM methods like GetCompute.
func (c *Client) SetARMContext(subscriptionID, resourceGroup, accountName string) {
c.subscriptionID = subscriptionID
c.resourceGroup = resourceGroup
c.accountName = accountName
}

// doARM executes an authenticated HTTP request against the ARM control plane.
// The path should be relative to https://management.azure.com/ (no leading slash).
func (c *Client) doARM(ctx context.Context, method, path string, body interface{}, apiVersion string) (*http.Response, error) {
reqURL := fmt.Sprintf("https://management.azure.com/%s?api-version=%s", path, apiVersion)

fmt.Printf("[DEBUG] %s %s\n", method, reqURL)

var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}

req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

if err := c.addAuth(ctx, req, ARMScope); err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}

return resp, nil
}

// doDataPlane executes an authenticated HTTP request against the data plane.
func (c *Client) doDataPlane(ctx context.Context, method, path string, body interface{}, queryParams ...string) (*http.Response, error) {
reqURL := fmt.Sprintf("%s%s/%s?api-version=%s", c.baseURL, c.subPath, path, c.apiVersion)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package client

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"

"azure.ai.customtraining/pkg/models"
)

const (
// armComputeAPIVersion is the ARM API version for compute operations.
// When compute GET moves to the data plane, this file can be removed
// in favor of a data plane compute method.
armComputeAPIVersion = "2026-01-15-preview"
)

// GetCompute retrieves a compute resource by name from the ARM control plane.
//
// ARM URL:
//
// GET https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{account}/computes/{name}?api-version=2026-01-15-preview
//
// Requires SetARMContext to be called first.
func (c *Client) GetCompute(ctx context.Context, computeName string) (*models.ComputeResource, error) {
if c.subscriptionID == "" || c.resourceGroup == "" || c.accountName == "" {
return nil, fmt.Errorf("ARM context not configured; call SetARMContext first")
}

path := fmt.Sprintf(
"subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/computes/%s",
url.PathEscape(c.subscriptionID),
url.PathEscape(c.resourceGroup),
url.PathEscape(c.accountName),
url.PathEscape(computeName),
)

resp, err := c.doARM(ctx, http.MethodGet, path, nil, armComputeAPIVersion)
if err != nil {
return nil, fmt.Errorf("failed to call ARM compute API: %w", err)
}
defer resp.Body.Close()

// Permission error — guide user to provide full ARM ID instead
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf(
"insufficient permissions to resolve compute '%s'.\n"+
" Provide the full ARM resource ID in your YAML instead:\n"+
" compute: /subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/computes/%s",
computeName, c.subscriptionID, c.resourceGroup, c.accountName, computeName,
)
}

if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("compute '%s' not found in account '%s'", computeName, c.accountName)
}

if resp.StatusCode != http.StatusOK {
return nil, c.HandleError(resp)
}

var result models.ComputeResource
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse compute response: %w", err)
}

if result.ID == "" {
return nil, fmt.Errorf("compute '%s' response missing resource ID", computeName)
}

return &result, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package models

// ComputeResource represents an ARM compute resource returned by the control plane.
type ComputeResource struct {
ID string `json:"id"`
Name string `json:"name"`
}