Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 3 additions & 5 deletions cli/azd/extensions/azure.ai.projects/go.mod
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
module azure.ai.projects


go 1.26.1

require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0
github.com/azure/azure-dev/cli/azd v1.25.0
github.com/fatih/color v1.18.0
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.80.0
)

require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect
Expand Down Expand Up @@ -76,7 +76,6 @@ require (
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/theckman/yacspin v0.13.12 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
Expand All @@ -98,7 +97,6 @@ require (
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
2 changes: 0 additions & 2 deletions cli/azd/extensions/azure.ai.projects/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsI
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY=
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import "github.com/azure/azure-dev/cli/azd/pkg/azdext"

// ensureExtensionContext returns a non-nil [azdext.ExtensionContext] so command
// constructors can be safely invoked from tests with a nil receiver. The SDK's
// [azdext.NewExtensionRootCommand] populates the real context (and its env-var
// fallback) before any leaf RunE runs, so tests that don't exercise RunE can
// safely pass nil here.
func ensureExtensionContext(extCtx *azdext.ExtensionContext) *azdext.ExtensionContext {
if extCtx == nil {
return &azdext.ExtensionContext{}
}
return extCtx
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"encoding/json"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// assertOutputFlagOptions verifies that cmd has the per-command --output flag
// configuration registered via [azdext.RegisterFlagOptions]. The SDK records
// these as cobra annotations rather than as a redeclared flag value, so we
// inspect cmd.Annotations directly rather than reading from cmd.Flags().
func assertOutputFlagOptions(t *testing.T, cmd *cobra.Command, wantDefault string, wantAllowed []string) {
t.Helper()
require.NotNil(t, cmd)
require.NotNil(t, cmd.Annotations, "cmd.Annotations should be set by RegisterFlagOptions")

got := cmd.Annotations["azdext.default/output"]
assert.Equal(t, wantDefault, got, "default for --output")

allowedJSON := cmd.Annotations["azdext.allowed-values/output"]
require.NotEmpty(t, allowedJSON, "allowed values for --output should be set")
var allowed []string
require.NoError(t, json.Unmarshal([]byte(allowedJSON), &allowed))
assert.Equal(t, wantAllowed, allowed, "allowed values for --output")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"context"
"fmt"
"time"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)

// projectContextConfigPath is the UserConfig path for the persisted project context.
const projectContextConfigPath = configPathPrefix + ".context"

// projectContextState is the JSON shape stored at extensions.ai-projects.context
// in ~/.azd/config.json.
type projectContextState struct {
Endpoint string `json:"endpoint"`
SetAt string `json:"setAt"`
}

// getProjectContext reads the persisted project context from global config.
// Returns (state, true, nil) when present, (zero, false, nil) when absent.
func getProjectContext(
ctx context.Context,
azdClient *azdext.AzdClient,
) (projectContextState, bool, error) {
ch, err := azdext.NewConfigHelper(azdClient)
if err != nil {
return projectContextState{}, false, fmt.Errorf("getProjectContext: %w", err)
}

var state projectContextState
found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state)
if err != nil {
return projectContextState{}, false,
fmt.Errorf("getProjectContext: failed to read config: %w", err)
}

if !found || state.Endpoint == "" {
return projectContextState{}, false, nil
}

return state, true, nil
}

// setProjectContext persists a validated project endpoint to global config.
// The caller is responsible for validating the endpoint before calling this function.
// Returns the setAt timestamp that was written to config.
func setProjectContext(
ctx context.Context,
azdClient *azdext.AzdClient,
endpoint string,
) (setAt string, err error) {
ch, chErr := azdext.NewConfigHelper(azdClient)
if chErr != nil {
return "", fmt.Errorf("setProjectContext: %w", chErr)
}

state := projectContextState{
Endpoint: endpoint,
SetAt: time.Now().UTC().Format(time.RFC3339),
}

if err := ch.SetUserJSON(ctx, projectContextConfigPath, state); err != nil {
return "", fmt.Errorf("setProjectContext: failed to write config: %w", err)
}

return state.SetAt, nil
}

// clearProjectContext removes the context subtree from global config.
// Returns the previously stored endpoint (empty if none was set).
// The operation is idempotent — calling it when no context is set is not an error.
func clearProjectContext(
ctx context.Context,
azdClient *azdext.AzdClient,
) (previousEndpoint string, err error) {
// Read existing state first so we can return the previous endpoint.
state, found, err := getProjectContext(ctx, azdClient)
if err != nil {
return "", err
}

if found {
Comment thread
huimiu marked this conversation as resolved.
Outdated
previousEndpoint = state.Endpoint
}

ch, chErr := azdext.NewConfigHelper(azdClient)
if chErr != nil {
return "", fmt.Errorf("clearProjectContext: %w", chErr)
}

if err := ch.UnsetUser(ctx, projectContextConfigPath); err != nil {
return "", fmt.Errorf("clearProjectContext: failed to clear config: %w", err)
}

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

package cmd

import (
"fmt"
"net/url"
"strings"

"azure.ai.projects/internal/exterrors"
)

// configPathPrefix is the UserConfig namespace for the Foundry project context
// persisted by this extension (stored in ~/.azd/config.json).
const configPathPrefix = "extensions.ai-projects"

// EndpointSource identifies where the resolved project endpoint came from.
type EndpointSource string

const (
// SourceFlag means the endpoint came from a --project-endpoint flag (level 1).
SourceFlag EndpointSource = "flag"
// SourceAzdEnv means the endpoint came from the active azd environment's
// AZURE_AI_PROJECT_ENDPOINT value (level 2).
SourceAzdEnv EndpointSource = "azdEnv"
// SourceGlobalConfig means the endpoint came from ~/.azd/config.json
// (extensions.ai-projects.context.endpoint) (level 3).
SourceGlobalConfig EndpointSource = "globalConfig"
// SourceFoundryEnv means the endpoint came from the FOUNDRY_PROJECT_ENDPOINT
// host environment variable (level 4).
SourceFoundryEnv EndpointSource = "foundryEnv"
)

// foundryHostSuffixes is the authoritative list of accepted Foundry host suffixes.
var foundryHostSuffixes = []string{
".services.ai.azure.com",
}

// projectEndpointPathPrefix is the expected path prefix for Foundry project endpoints.
const projectEndpointPathPrefix = "/api/projects/"

// isFoundryHost reports whether the hostname ends with one of the recognized
// Foundry host suffixes.
func isFoundryHost(hostname string) bool {
h := strings.ToLower(hostname)
for _, suffix := range foundryHostSuffixes {
if strings.HasSuffix(h, suffix) {
return true
}
}
return false
}

// validateProjectEndpoint validates and normalizes a Foundry project endpoint URL.
//
// The URL must be an absolute https:// URL whose host ends with a recognized
// Foundry suffix (see [foundryHostSuffixes]). Whitespace is trimmed, trailing
// slashes are stripped, and the result is returned in normalized form.
//
// The second return value is true when the path does not look like
// /api/projects/<proj> — callers may use this as a non-fatal warning.
func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false, exterrors.Validation(
exterrors.CodeInvalidParameter,
"project endpoint must not be empty",
"provide a Foundry project endpoint URL "+
"(e.g. https://<account>.services.ai.azure.com/api/projects/<project>)",
)
}

u, parseErr := url.Parse(raw)
if parseErr != nil {
return "", false, exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("invalid project endpoint URL: %v", parseErr),
"provide a valid https:// Foundry project endpoint URL",
)
}

if !strings.EqualFold(u.Scheme, "https") {
return "", false, exterrors.Validation(
exterrors.CodeInvalidParameter,
"project endpoint must use https",
"provide an https:// URL",
)
}

host := u.Hostname()
if host == "" || !isFoundryHost(host) {
return "", false, exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf(
"project endpoint host %q is not a recognized Foundry host (*%s)",
host, foundryHostSuffixes[0],
),
"the host must end with "+foundryHostSuffixes[0],
)
}

if u.Port() != "" {
return "", false, exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("project endpoint host %q must not include a port", u.Host),
"remove the explicit port from the URL",
)
}

// Normalize: lowercase host, strip trailing slash.
path := strings.TrimRight(u.EscapedPath(), "/")
normalized = fmt.Sprintf("https://%s%s", strings.ToLower(host), path)

// Warn when the path does not look like /api/projects/<proj>.
if !strings.HasPrefix(path, projectEndpointPathPrefix) ||
strings.TrimPrefix(path, projectEndpointPathPrefix) == "" {
pathWarning = true
}

return normalized, pathWarning, nil
}

// noProjectEndpointError returns the structured dependency error used when no
// project endpoint could be resolved from any source.
func noProjectEndpointError() error {
return exterrors.Dependency(
exterrors.CodeMissingProjectEndpoint,
"no Foundry project endpoint resolved",
"persist a workspace default with `azd ai project set <endpoint>`, "+
"or set AZURE_AI_PROJECT_ENDPOINT in the active azd environment, "+
"or export FOUNDRY_PROJECT_ENDPOINT in your shell",
)
}
Loading
Loading