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
204 changes: 204 additions & 0 deletions cli/azd/docs/design/extension-flag-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# Spec: Global Flags and Extension Flag Dispatch

## Status

**Documenting existing behavior** — this spec formalizes the flag contract that already exists in the azd + extension SDK implementation, and adds enforcement that was previously missing.

## Goal

Define how azd handles global flags when dispatching to extensions, including the pre-parsing pipeline, environment variable propagation, and the reserved flag registry that prevents namespace collisions.

## Background

azd extensions are standalone binaries that azd discovers, installs, and invokes as subcommands. When a user runs `azd model custom create --endpoint https://...`, azd:

1. Pre-parses its own global flags from the full argument list
2. Launches the extension binary as a child process
3. Passes the raw arguments **and** global flag values (via environment variables) to the extension

This creates a **shared flag namespace** — both azd and the extension parse the same `argv`. If an extension registers a flag that collides with an azd global flag (e.g., both use `-e`), azd's pre-parser consumes the value for its own purpose, and the extension either gets the wrong value or causes azd to error.

Issue [#7271](https://github.com/Azure/azure-dev/issues/7271) demonstrated this: the `azd model` extension used `-e` for `--endpoint` (a URL), but azd's pre-parser treated the URL as an environment name and failed validation.

## Architecture

### Flag Flow Diagram

```
User runs: azd model custom create -e https://example.com/api
┌─────────────────────────────────────────────────────┐
│ azd host process │
│ │
│ 1. ParseGlobalFlags(args) │
│ - Reads: -e/--environment, --debug, --cwd, etc. │
│ - UnknownFlags: true (ignores extension flags) │
│ - Populates GlobalCommandOptions │
│ │
│ 2. extensions.go: DisableFlagParsing: true │
│ - Cobra does NOT parse extension-specific flags │
│ - Raw args passed through to extension │
│ │
│ 3. runner.go: Invoke() │
│ - Converts GlobalCommandOptions → AZD_* env vars │
│ - Launches extension binary with: │
│ - Args: original argv (including -e value) │
│ - Env: AZD_ENVIRONMENT, AZD_DEBUG, etc. │
└──────────────────────┬──────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Extension binary (child process) │
│ │
│ 1. NewExtensionRootCommand() [SDK] │
│ - Registers SAME global flags: │
│ --environment/-e, --debug, --cwd/-C, etc. │
│ - Falls back to AZD_* env vars if not on CLI │
│ │
│ 2. Extension-specific subcommands │
│ - Register their OWN flags (--model, --version) │
│ - Must NOT collide with reserved flags │
└─────────────────────────────────────────────────────┘
```

### Key Insight

Both azd and the extension parse the **same arguments**. azd does not strip its global flags before passing args to extensions. This means:

- If an extension reuses `-e` for `--endpoint`, azd's pre-parser sees `-e https://example.com/api` and tries to use the URL as an environment name
- The extension then also receives `-e https://example.com/api` in its args, but the SDK's root command binds `-e` to `--environment`, so the extension's own `-e` flag on a subcommand creates a conflict

This is not a new restriction — it has been true since the extension system was designed.

## Global Flags (Host Side)

azd's global flags are registered through several mechanisms. All of them are reserved — extensions must not reuse these names regardless of how they are registered.

### Pre-parsed Global Flags

These flags are registered in `CreateGlobalFlagSet()` (`cmd/auto_install.go`) and pre-parsed by `ParseGlobalFlags()` **before** command dispatch. They are available to middleware and DI resolution even for extension commands:

| Long Name | Short | Type | Default | Hidden | Description |
|-----------|-------|------|---------|--------|-------------|
| `cwd` | `C` | string | `""` | No | Sets the current working directory |
| `debug` || bool | `false` | No | Enables debugging and diagnostics logging |
| `no-prompt` || bool | `false` | No | Accepts default value instead of prompting |
| `trace-log-file` || string | `""` | Yes | Write a diagnostics trace to a file |
| `trace-log-url` || string | `""` | Yes | Send traces to an OpenTelemetry-compatible endpoint |

### Root Command Persistent Flags

These flags are registered on the root command's persistent flag set in `root.go` and are available to all subcommands:

| Long Name | Short | Type | Default | Description |
|-----------|-------|------|---------|-------------|
| `environment` | `e` | string | `$AZURE_ENV_NAME` | The name of the environment to use |
| `output` | `o` | string | `"default"` | The output format (json, table, none) |

### Cobra Built-in and Custom Command Flags

| Long Name | Short | Description |
|-----------|-------|-------------|
| `help` | `h` | Help for the current command (cobra built-in) |
| `docs` || Opens documentation for the current command |

### Pre-parsing Behavior

`ParseGlobalFlags` uses `pflag.ParseErrorsAllowlist{UnknownFlags: true}` to silently ignore flags it doesn't recognize. This allows extension-specific flags (like `--model`, `--version`) to pass through without error. However, any flag that matches an azd global flag **will be consumed** by the pre-parser.

## Global Flags (Extension SDK Side)

The extension SDK's `NewExtensionRootCommand()` (`pkg/azdext/extension_command.go`) registers these persistent flags on every extension's root command:

| Long Name | Short | Type | Default | Env Var Fallback |
|-----------|-------|------|---------|-----------------|
| `environment` | `e` | string | `""` | `AZD_ENVIRONMENT` |
| `cwd` | `C` | string | `""` | `AZD_CWD` |
| `debug` || bool | `false` | `AZD_DEBUG` |
| `no-prompt` || bool | `false` | `AZD_NO_PROMPT` |
| `output` | `o` | string | `"default"` ||
| `trace-log-file` || string | `""` ||
| `trace-log-url` || string | `""` ||

### Env Var Propagation

azd passes global flag values to extensions via two mechanisms:

1. **Environment variables** (`runner.go`): `AZD_DEBUG`, `AZD_NO_PROMPT`, `AZD_CWD`, `AZD_ENVIRONMENT`
2. **Raw args**: The original command-line arguments are passed through unchanged

The SDK's `PersistentPreRunE` checks if each flag was explicitly set on the command line; if not, it falls back to the corresponding `AZD_*` environment variable. This dual-path design ensures global values are available whether the extension is invoked via azd or directly during development.

## Reserved Flags

### Definition

A **reserved flag** is any flag that azd pre-parses from the command line before dispatching to extensions, or that the extension SDK registers on the root command. Extensions must not register flags with the same long name or short name on their subcommands.

### The Reserved List

The canonical list is maintained in two locations that are kept in sync by a test:

- **Host side**: `internal/reserved_flags.go``reservedFlags` slice with `ReservedFlags()` getter and lookup helpers
- **SDK side**: `pkg/azdext/reserved_flags.go``reservedGlobalFlags` slice with validation

| Long Name | Short | Reason Reserved |
|-----------|-------|----------------|
| `environment` | `e` | azd pre-parses for env selection; SDK registers on root |
| `cwd` | `C` | azd pre-parses for working directory; SDK registers on root |
| `debug` || azd pre-parses for debug mode; SDK registers on root |
| `no-prompt` || azd pre-parses for non-interactive mode; SDK registers on root |
| `output` | `o` | SDK registers on root for output format |
| `help` | `h` | cobra built-in; universal across all commands |
| `docs` || azd root command flag |
| `trace-log-file` || azd pre-parses for telemetry; SDK registers on root |
| `trace-log-url` || azd pre-parses for telemetry; SDK registers on root |

## Enforcement

### SDK-Level Validation

`ValidateNoReservedFlagConflicts(root)` is called in `azdext.Run()` before command execution. It:

1. Walks the entire command tree
2. For each command's flags, checks both long and short names against the reserved list
3. Skips flags on the root command's persistent flag set (those are the SDK-provided azd-compatible flags)
4. Returns a detailed error listing all conflicts with remediation guidance

Any extension built with the azd SDK gets this check automatically — no opt-in required.

### Sync Test

A test (`TestReservedFlagsInSyncWithInternal`) ensures the SDK-side and host-side reserved flag lists stay in sync. If a developer adds a new global flag to one list but not the other, the test fails.

## Adding a New Global Flag

When azd needs a new global flag:

1. Add the flag to `CreateGlobalFlagSet()` in `cmd/auto_install.go`
2. Add parsing logic to `ParseGlobalFlags()` in `cmd/auto_install.go`
3. Add to `reservedFlags` in `internal/reserved_flags.go`
4. Add to `reservedGlobalFlags` in `pkg/azdext/reserved_flags.go`
5. If it should be available to extensions:
- Register it in `NewExtensionRootCommand()` in `pkg/azdext/extension_command.go`
- Add env var propagation in `runner.go`
- Add env var fallback in `NewExtensionRootCommand`'s `PersistentPreRunE`
6. Run tests — the sync test will catch mismatches between steps 3 and 4

The reserved flag registry makes this process explicit and safe: any extension that happens to use the new flag name will get a clear error at startup instead of a mysterious runtime failure.

## Implementation References

| Component | File |
|-----------|------|
| Global flag registration | `cmd/auto_install.go``CreateGlobalFlagSet()` |
| Global flag pre-parsing | `cmd/auto_install.go``ParseGlobalFlags()` |
| Extension dispatch | `cmd/extensions.go``DisableFlagParsing: true` |
| InvokeOptions construction | `cmd/extensions.go` — global options propagation |
| Env var propagation | `pkg/extensions/runner.go``Invoke()` |
| SDK flag registration | `pkg/azdext/extension_command.go``NewExtensionRootCommand()` |
| SDK env var fallback | `pkg/azdext/extension_command.go``PersistentPreRunE` |
| Reserved flags (host) | `internal/reserved_flags.go` |
| Reserved flags (SDK) | `pkg/azdext/reserved_flags.go` |
| SDK enforcement | `pkg/azdext/reserved_flags.go``ValidateNoReservedFlagConflicts()` |
| Enforcement hook | `pkg/azdext/run.go` |
57 changes: 57 additions & 0 deletions cli/azd/docs/extensions/extensions-style-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,63 @@ This guide provides design guidelines and best practices for developing extensio
- Reuse established parameter patterns across new commands
- Maintain consistent naming conventions (e.g., `--subscription`, `--name`, `--type`)
- Provide sensible defaults to reduce cognitive load
- **Do not reuse reserved global flag names** — see section below

### Reserved Global Flags

azd pre-parses a set of global flags from the command line **before** dispatching to extensions.
The extension SDK (`NewExtensionRootCommand`) also registers these same flags on every
extension's root command. Because both azd and the extension parse the same `argv`, extensions
**must not** register flags that collide with these reserved names.

This is not a new restriction — it has been true since the extension system was designed.
The SDK now enforces it at startup via `ValidateNoReservedFlagConflicts()`.

#### Reserved flag names

| Long Name | Short | Purpose |
|-----------|-------|---------|
| `environment` | `e` | Selects the azd environment |
| `cwd` | `C` | Sets the working directory |
| `debug` || Enables debug logging |
| `no-prompt` || Non-interactive mode |
| `output` | `o` | Output format (json, table, none) |
| `help` | `h` | Command help (cobra built-in) |
| `docs` || Opens command documentation |
| `trace-log-file` || Diagnostics trace file |
| `trace-log-url` || OpenTelemetry trace endpoint |

#### What this means for extension authors

**DO:**
- Use any flag name that is not in the table above
- Use any single-letter short flag except `e`, `C`, `o`, `h`
- Access the environment name via `extCtx.Environment` (the SDK provides it automatically)
- Ignore reserved flags you don't need — the SDK handles them

**DON'T:**
- Register `--environment` or `-e` on any subcommand (use `--env-name` or `--target-env` if you need a second environment reference)
- Register `--debug`, `--cwd`, `--output`, `--help`, or their short forms for a different purpose
- Assume you can "override" a global flag on a subcommand — azd's pre-parser will consume it first

#### What happens if you collide

If your extension uses the azd SDK (`azdext.Run()`), the SDK validates all flags at startup.
A collision produces a clear error:

```
extension defines flags that conflict with reserved azd global flags:
- command "custom create": flag --endpoint/-e conflicts with reserved global flag --environment
(short flag -e is reserved by azd for --environment)
Remove or rename these flags to avoid conflicts with azd's global flags.
Reserved flags: environment, cwd, debug, no-prompt, output, help, docs, trace-log-file, trace-log-url
```

#### Background

For the full technical specification of how flags flow between azd and extensions, including
the pre-parsing pipeline, environment variable propagation, and enforcement implementation,
see [Extension Flag Architecture Spec](../design/extension-flag-architecture.md).

### 3. **Help and Discoverability**

Expand Down
88 changes: 88 additions & 0 deletions cli/azd/internal/reserved_flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package internal

// ReservedFlag describes a global flag that is owned by azd and must not be reused
// by extensions for a different purpose. Extensions that register a flag with the
// same short or long name will shadow the global flag, causing unpredictable behavior
// when azd tries to parse the command line before dispatching to the extension.
type ReservedFlag struct {
// Long is the full flag name (e.g. "environment"). Always present.
Long string
// Short is the single-character alias (e.g. "e"). Empty when there is no short form.
Short string
// Description explains the flag's purpose in azd.
Description string
}

// reservedFlags is the canonical list of global flags that extensions must not reuse.
// It is derived from CreateGlobalFlagSet (auto_install.go), the root command's
// persistent flags, and the extension SDK's built-in flag set (extension_command.go).
//
// Keep this list in sync whenever a new global flag is added to azd.
var reservedFlags = []ReservedFlag{
{Long: "environment", Short: "e", Description: "The name of the environment to use."},
{Long: "cwd", Short: "C", Description: "Sets the current working directory."},
{Long: "debug", Short: "", Description: "Enables debugging and diagnostics logging."},
{Long: "no-prompt", Short: "", Description: "Accepts the default value instead of prompting."},
{Long: "output", Short: "o", Description: "The output format (json, table, none)."},
{Long: "help", Short: "h", Description: "Help for the current command."},
{Long: "docs", Short: "", Description: "Opens the documentation for the current command."},
{Long: "trace-log-file", Short: "", Description: "Write a diagnostics trace to a file."},
{Long: "trace-log-url", Short: "", Description: "Send traces to an OpenTelemetry-compatible endpoint."},
}

// ReservedFlags returns a copy of the reserved flags list.
// The copy prevents callers from mutating the canonical list.
func ReservedFlags() []ReservedFlag {
out := make([]ReservedFlag, len(reservedFlags))
copy(out, reservedFlags)
return out
}

// reservedShortFlags is an index of short flag names built once at initialization time.
var reservedShortFlags = func() map[string]ReservedFlag {
m := make(map[string]ReservedFlag, len(reservedFlags))
for _, f := range reservedFlags {
if f.Short != "" {
m[f.Short] = f
}
}
return m
}()

// reservedLongFlags is an index of long flag names built once at initialization time.
var reservedLongFlags = func() map[string]ReservedFlag {
m := make(map[string]ReservedFlag, len(reservedFlags))
for _, f := range reservedFlags {
m[f.Long] = f
}
return m
}()

// IsReservedShortFlag returns true when the given single-character flag name
// (without the leading "-") is reserved by azd as a global flag.
func IsReservedShortFlag(short string) bool {
_, ok := reservedShortFlags[short]
return ok
}

// IsReservedLongFlag returns true when the given long flag name
// (without the leading "--") is reserved by azd as a global flag.
func IsReservedLongFlag(long string) bool {
_, ok := reservedLongFlags[long]
return ok
}

// GetReservedShortFlag returns the ReservedFlag for the given short name, if any.
func GetReservedShortFlag(short string) (ReservedFlag, bool) {
f, ok := reservedShortFlags[short]
return f, ok
}

// GetReservedLongFlag returns the ReservedFlag for the given long name, if any.
func GetReservedLongFlag(long string) (ReservedFlag, bool) {
f, ok := reservedLongFlags[long]
return f, ok
}
Loading
Loading