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
56 changes: 56 additions & 0 deletions cli/azd/extensions/featureflags/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Shared Foundry feature access

<!-- cspell:ignore featureflags strconv -->

`featureflags` is the common opt-in reader for first-party Foundry extensions. It is self-contained and needs no azd core change. It reads only through existing azd gRPC services (`UserConfig`, `Environment`, `Project`).

It belongs to the root `cli/azd` Go module, like the shared `extensions/telemetry` package. An extension module imports it as `github.com/azure/azure-dev/cli/azd/extensions/featureflags` once its `github.com/azure/azure-dev/cli/azd` requirement points at a version that contains this package.

This package adds access only. No existing command or feature uses it yet.

## API

```go
var hosted = featureflags.Feature{
ID: "agents.hosted",
EnvVar: "AZD_FOUNDRY_EXT_ENABLE_AGENTS_HOSTED",
}

enabled, err := featureflags.IsEnabled(
extCtx.Context(),
azdClient,
hosted,
extCtx.Environment,
)
```

Each extension declares its own `Feature`. There is no shared registry and no change to the core alpha feature list.

Pass the parsed `azdext.ExtensionContext.Environment` rather than discovering the default yourself. A nonempty name is forwarded to `Environment.GetValues` unchanged, so `-e/--environment` takes precedence over the project default. An invalid explicit selection is an error. An empty name uses the default environment if there is one.

## Independent enablement sources

A feature is enabled if **any** of these sources opts in:

| Source | Read | Enabling value |
| --- | --- | --- |
| Global user config | `UserConfig.Get("alpha")` (`azd config set`) | Case-insensitive `on` at `alpha.<ID>` or `alpha.all`, same as azd core |
| Process environment | Only `EnvVar` | A true value accepted by `strconv.ParseBool` |
| Selected azd environment | `EnvVar` in its `.env` values from `Environment.GetValues` (`azd env set`) | A true value accepted by `strconv.ParseBool` |
| Project service environment | `EnvVar` in each `services[].env` map from `Project.Get` | A true value accepted by `strconv.ParseBool` |

Canonical environment names are:

- `AZD_FOUNDRY_EXT_ENABLE_<FEATURE>` for shared capabilities.
- `AZD_FOUNDRY_EXT_ENABLE_AGENTS_<FEATURE>` for agent-specific capabilities.
- `AZD_FOUNDRY_EXT_ENABLE_MODELS_<FEATURE>` for model-specific capabilities.

All service hosts count. Service maps stay separate, so a true value in any service wins regardless of other services' values or order.

False, empty, missing, and unrecognized values contribute no signal and never override another source's opt-in. Non-string config values are also non-enabling. There is no `ALL` environment toggle, `AZD_ALPHA_ENABLE_*` alias, or experimentation default.

## Scope and errors

Each call loads fresh snapshots and discards them. There is no global cache, process environment mutation, output, authentication call, or configuration write.

Outside a project, only process environment and user config apply. In a project without a default environment, process, config, and service opt-ins still apply. Only the host's exact no-project and no-default-environment errors count as absence, and only when no environment name was supplied. Other project, dotenv, config, decoding, and transport errors are returned with context, even when another source opts in.
162 changes: 162 additions & 0 deletions cli/azd/extensions/featureflags/featureflags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

// cspell:ignore featureflags

// Package featureflags resolves shared Foundry extension opt-ins.
package featureflags

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// Feature declares one extension opt-in.
type Feature struct {
// ID is the user config key under alpha, for example "agents.hosted".
ID string
// EnvVar is the only environment key read, for example
// "AZD_FOUNDRY_EXT_ENABLE_AGENTS_HOSTED".
EnvVar string
}

const (
alphaKey = "alpha"
allFeatureID = "all"
enabledValue = "on"
)

// Host errors for an absent optional scope. They match
// azdcontext.ErrNoProject and environment.ErrDefaultEnvironmentNotFound
// without importing those packages and their dependencies.
var (
errNoProject = errors.New("no project exists; to create a new project, run `azd init`")
errDefaultEnvironmentNotFound = errors.New("default environment not found")
)

type azdClient interface {
Project() azdext.ProjectServiceClient
Environment() azdext.EnvironmentServiceClient
UserConfig() azdext.UserConfigServiceClient
}

// IsEnabled resolves feature for one invocation.
// Pass ExtensionContext.Environment to honor -e/--environment.
// An empty environmentName uses the project default, if one exists.
// Loading failures are returned even when another source enables it.
func IsEnabled(
ctx context.Context,
client *azdext.AzdClient,
feature Feature,
environmentName string,
) (bool, error) {
if client == nil {
return false, errors.New("featureflags.IsEnabled: client must not be nil")
}
if feature.ID == "" || feature.EnvVar == "" {
return false, errors.New("featureflags.IsEnabled: feature ID and EnvVar must not be empty")
}

return isEnabled(ctx, client, feature, environmentName)
}

func isEnabled(
ctx context.Context,
client azdClient,
feature Feature,
environmentName string,
) (bool, error) {
enabled := envEnabled(os.Getenv(feature.EnvVar))

configResponse, err := client.UserConfig().Get(ctx, &azdext.GetUserConfigRequest{Path: alphaKey})
if err != nil {
return false, fmt.Errorf("reading user config for feature %q: %w", feature.ID, err)
}
if configResponse == nil {
return false, fmt.Errorf("reading user config for feature %q: missing response", feature.ID)
}
if configResponse.Found {
var alphaValues any
if err := json.Unmarshal(configResponse.Value, &alphaValues); err != nil {
return false, fmt.Errorf("decoding alpha user config for feature %q: %w", feature.ID, err)
}
enabled = configEnabled(alphaValues, feature.ID) || enabled
}

projectResponse, err := client.Project().Get(ctx, &azdext.EmptyRequest{})
switch {
case err != nil && environmentName == "" && isAbsentScope(err, errNoProject):
// Without a project, neither local scope is available.
return enabled, nil
case err != nil:
return false, fmt.Errorf("loading project for feature %q: %w", feature.ID, err)
case projectResponse.GetProject() == nil:
return false, fmt.Errorf("loading project for feature %q: missing project response", feature.ID)
}

valuesResponse, err := client.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{
Name: environmentName,
})
switch {
case err != nil && environmentName == "" && isAbsentScope(err, errDefaultEnvironmentNotFound):
// A project need not have a default environment.
case err != nil:
return false, fmt.Errorf("reading azd environment %q for feature %q: %w", environmentName, feature.ID, err)
case valuesResponse == nil:
return false, fmt.Errorf("reading azd environment for feature %q: missing response", feature.ID)
default:
for _, value := range valuesResponse.KeyValues {
if value.GetKey() == feature.EnvVar && envEnabled(value.GetValue()) {
enabled = true
}
}
}

// Each service map counts on its own, so one false never hides another true.
for _, service := range projectResponse.Project.Services {
if envEnabled(service.GetEnvironment()[feature.EnvVar]) {
enabled = true
}
}

return enabled, nil
}

// envEnabled reports whether value is a true strconv.ParseBool value.
// False, empty, and unparsable values contribute no signal.
func envEnabled(value string) bool {
enabled, err := strconv.ParseBool(value)
return err == nil && enabled
}

// configEnabled matches azd core: "on" (any case) at alpha.all or alpha.<id>.
// Other values, including non-string values, contribute no signal.
func configEnabled(alphaValues any, id string) bool {
userConfig := config.NewConfig(map[string]any{alphaKey: alphaValues})
for _, key := range []string{allFeatureID, id} {
if value, ok := userConfig.GetString(alphaKey + "." + key); ok && strings.EqualFold(value, enabledValue) {
return true
}
}

return false
}

func isAbsentScope(err error, sentinel error) bool {
// The host sends these sentinels as Unknown, without details.
// Match the exact sentinel, never a broad transport status.
// A plain error converts to Unknown with its own message.
st, _ := status.FromError(err)
return st.Code() == codes.Unknown &&
st.Message() == sentinel.Error() && len(st.Proto().Details) == 0
}
Loading
Loading