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
14 changes: 14 additions & 0 deletions cli/azd/cmd/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/auth"
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/azd"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/azsdk"
"github.com/azure/azure-dev/cli/azd/pkg/azsdk/storage"
"github.com/azure/azure-dev/cli/azd/pkg/cloud"
Expand Down Expand Up @@ -1015,6 +1016,19 @@ func registerCommonDependencies(container *ioc.NestedContainer) {
container.MustRegisterSingleton(grpcserver.NewServiceTargetService)
container.MustRegisterSingleton(grpcserver.NewFrameworkService)
container.MustRegisterSingleton(grpcserver.NewProvisioningService)
container.MustRegisterSingleton(grpcserver.NewValidationService)
// Bind the concrete ValidationService to both the gRPC interface and
// the dispatcher interface so DI resolves both from the same instance.
container.MustRegisterSingleton(
func(svc *grpcserver.ValidationService) azdext.ValidationServiceServer {
return svc
},
)
container.MustRegisterSingleton(
func(svc *grpcserver.ValidationService) provisioning.ValidationCheckDispatcher {
Comment thread
vhvb1989 marked this conversation as resolved.
return svc
},
)
container.MustRegisterSingleton(grpcserver.NewAiModelService)
container.MustRegisterScoped(grpcserver.NewCopilotService)

Expand Down
4 changes: 4 additions & 0 deletions cli/azd/cmd/middleware/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var (
extensions.ServiceTargetProviderCapability,
extensions.FrameworkServiceProviderCapability,
extensions.ProvisioningProviderCapability,
extensions.ValidationProviderCapability,
}
)

Expand Down Expand Up @@ -192,6 +193,9 @@ func (m *ExtensionsMiddleware) Run(ctx context.Context, next NextFn) (*actions.A
if err := ext.WaitUntilReady(readyCtx); err != nil {
elapsed := time.Since(startTime)
log.Printf("'%s' extension failed to become ready after %v: %v\n", ext.Id, elapsed, err)
if reportedErr := ext.GetReportedError(); reportedErr != nil {
log.Printf("'%s' extension reported error: %v\n", ext.Id, reportedErr)
}

// Track failed extensions for warning display
mu.Lock()
Expand Down
3 changes: 2 additions & 1 deletion cli/azd/cmd/middleware/middleware_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,8 @@ func TestListenCapabilities_ContainsExpectedValues(t *testing.T) {
require.Contains(t, listenCapabilities, extensions.ServiceTargetProviderCapability)
require.Contains(t, listenCapabilities, extensions.FrameworkServiceProviderCapability)
require.Contains(t, listenCapabilities, extensions.ProvisioningProviderCapability)
require.Len(t, listenCapabilities, 4)
require.Contains(t, listenCapabilities, extensions.ValidationProviderCapability)
require.Len(t, listenCapabilities, 5)
}

// ---------------------------------------------------------------------------
Expand Down
72 changes: 72 additions & 0 deletions cli/azd/docs/design/local-preflight-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,81 @@ pkg/
│ ├── role_assignment_check_test.go # Tests for the role assignment check
│ ├── generate_bicep_param_test.go # Tests for .bicepparam generation
│ └── bicep_provider.go # validatePreflight() integration, checkRoleAssignmentPermissions
├── infra/provisioning/
│ └── validation_dispatcher.go # ValidationCheckDispatcher interface (DI decoupling)
├── output/ux/
│ ├── preflight_report.go # PreflightReport UxItem
│ └── preflight_report_test.go # Tests for PreflightReport
└── tools/bicep/
└── bicep.go # Snapshot() method, SnapshotOptions builder
```

## Extension-Provided Checks

Extensions can contribute validation checks to the local preflight pipeline using
the `validation-provider` capability. This allows extensions to inspect the Bicep
deployment data (ARM template, snapshot, parameters, location) and return additional
warnings or errors that are merged into the preflight report.

### How It Works

1. The extension declares `validation-provider` in its `extension.yaml` capabilities.
2. During startup, the extension registers one or more checks with a `check_type`
(e.g., `"local-preflight"`) and a stable `rule_id`.
3. When `BicepProvider.validatePreflight()` runs, after the built-in checks complete,
it dispatches to all extension-registered checks matching `check_type: "local-preflight"`.
4. Each extension check receives a context map with:
- `resources_snapshot` — Bicep snapshot JSON (`predictedResources`)
- `predicted_resources` — Parsed resource array from the snapshot
- `arm_template` — Compiled ARM template JSON
- `arm_parameters` — Resolved ARM parameters JSON
- `env_location` — Azure location string
5. The extension returns `ValidationCheckResult` items (severity, message, suggestion, links)
which are appended to the preflight report.

### Extension Code Example

```go
// In your extension's listen command:
host := azdext.NewExtensionHost(azdClient).
WithValidationCheck(azdext.ValidationCheckRegistration{
CheckType: "local-preflight",
RuleID: "my_naming_rule",
Factory: func() azdext.ValidationCheckProvider {
return &MyNamingCheck{}
},
})

// The check implementation:
type MyNamingCheck struct{}

func (c *MyNamingCheck) Validate(
ctx context.Context,
valCtx *azdext.ValidationContext,
req *azdext.ValidationCheckRequest,
) (*azdext.ValidationCheckResponse, error) {
resources, err := valCtx.ParsePredictedResources()
if err != nil || len(resources) == 0 {
return &azdext.ValidationCheckResponse{}, nil
}

// Inspect resources, return results...
return &azdext.ValidationCheckResponse{
Results: results,
}, nil
}
```

### Failure Handling

If an extension check returns an error (the `Validate` method fails), the error is
logged as a warning but does **not** block the deployment. Only the extension's
results are omitted. Built-in check failures still follow the standard error behavior
described above.

### Future Check Types

The `check_type` field is designed for extensibility. Currently only
`"local-preflight"` is supported, but future check types (e.g., `"project-config"`,
`"auth"`) can be added without changing the protocol. Each check type defines its
own context keys.
32 changes: 32 additions & 0 deletions cli/azd/docs/extensions/extension-framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,36 @@ Extensions can provide AI agent tools through the Model Context Protocol, enabli
- Azure service automation for AI agents
- Custom development workflows for AI-assisted development

##### Validation Provider (`validation-provider`)

> Extensions must declare the `validation-provider` capability in their `extension.yaml` file.

Extensions can contribute validation checks to azd's validation pipeline. Currently
supported check types:

- **`local-preflight`** — Checks run during `azd provision` before deployment. The
extension receives the Bicep snapshot, ARM template, ARM parameters, and Azure
location as context.

Future check types (e.g., `project-config`, `auth`) can be added without protocol
changes.

**Example:**

```go
host := azdext.NewExtensionHost(azdClient).
WithValidationCheck(azdext.ValidationCheckRegistration{
CheckType: "local-preflight",
RuleID: "my_naming_rule",
Factory: func() azdext.ValidationCheckProvider {
return &MyNamingCheck{}
},
})
```

See [`local-preflight-validation.md`](../design/local-preflight-validation.md#extension-provided-checks)
for full details on the check interface and context keys.

#### Future Considerations

Future ideas include:
Expand Down Expand Up @@ -1031,6 +1061,7 @@ Extensions can declare the following capabilities in their manifest:
- **`service-target-provider`**: Provide custom service deployment targets
- **`framework-service-provider`**: Provide custom language frameworks and build systems
- **`provisioning-provider`**: Provide a custom infrastructure provisioning experience (alternative to Bicep / Terraform)
- **`validation-provider`**: Contribute validation checks to azd's preflight and future validation pipelines
- **`metadata`**: Provide comprehensive metadata about commands and configuration schemas

#### Complete Extension Manifest Example
Expand All @@ -1051,6 +1082,7 @@ capabilities:
- lifecycle-events
- service-target-provider
- framework-service-provider
- validation-provider
- mcp-server
- metadata

Expand Down
Binary file added cli/azd/extensions/microsoft.azd.demo/demo
Binary file not shown.
3 changes: 2 additions & 1 deletion cli/azd/extensions/microsoft.azd.demo/extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace: demo
displayName: Demo Extension
description: This extension provides examples of the azd extension framework.
usage: azd demo <command> [options]
version: 0.6.0
version: 0.7.0
language: go
capabilities:
- custom-commands
Expand All @@ -13,6 +13,7 @@ capabilities:
- service-target-provider
- framework-service-provider
- provisioning-provider
- validation-provider
- metadata
providers:
- name: demo
Expand Down
11 changes: 10 additions & 1 deletion cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
)

func newListenCommand() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "listen",
Short: "Starts the extension and listens for events.",
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -38,6 +38,13 @@ func newListenCommand() *cobra.Command {
WithProvisioningProvider("demo", func() azdext.ProvisioningProvider {
return project.NewDemoProvisioningProvider(azdClient)
}).
WithValidationCheck(azdext.ValidationCheckRegistration{
CheckType: "local-preflight",
RuleID: "demo_warning",
Factory: func() azdext.ValidationCheckProvider {
return project.NewDemoValidationCheck()
},
}).
WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error {
for i := 1; i <= 20; i++ {
fmt.Printf("%d. Doing important work in extension...\n", i)
Expand Down Expand Up @@ -88,4 +95,6 @@ func newListenCommand() *cobra.Command {
return nil
},
}

return cmd
}
8 changes: 8 additions & 0 deletions cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@ func NewRootCommand() *cobra.Command {
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
Annotations: map[string]string{
"azd-sdk-root": "true",
},
}

// Register reserved azd flags so that `listen --debug` doesn't fail.
flags := rootCmd.PersistentFlags()
flags.Bool("debug", false, "Enables debug and diagnostics logging")
flags.Bool("no-prompt", false, "Runs without prompts")

rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})

rootCmd.AddCommand(newListenCommand())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package project

import (
"context"
"fmt"

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

// DemoValidationCheck is a sample validation check that always returns
// a warning to demonstrate the validation-provider capability.
type DemoValidationCheck struct{}

// NewDemoValidationCheck creates a new DemoValidationCheck.
func NewDemoValidationCheck() *DemoValidationCheck {
return &DemoValidationCheck{}
}

// Validate implements azdext.ValidationCheckProvider.
func (c *DemoValidationCheck) Validate(
_ context.Context,
valCtx *azdext.ValidationContext,
_ *azdext.ValidationCheckRequest,
) (*azdext.ValidationCheckResponse, error) {
msg := "This warning was intentional and generated by the demo extension."

if resources, err := valCtx.ParsePredictedResources(); err == nil && len(resources) > 0 {
msg = fmt.Sprintf(
"%s Bicep snapshot contains %d predicted resources.",
msg, len(resources),
)
}

return &azdext.ValidationCheckResponse{
Results: []*azdext.ValidationCheckResult{
{
Severity: azdext.ValidationCheckSeverity_VALIDATION_CHECK_SEVERITY_WARNING,
DiagnosticId: "demo_warning",
Message: msg,
},
},
}, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package project

import (
"testing"

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

func TestDemoValidationCheck_AlwaysReturnsWarning(t *testing.T) {
check := NewDemoValidationCheck()

t.Run("no ARM template", func(t *testing.T) {
valCtx := &azdext.ValidationContext{
ContextID: "test-ctx",
CheckType: "local-preflight",
Data: map[string][]byte{},
}
req := &azdext.ValidationCheckRequest{
CheckType: "local-preflight",
RuleId: "demo_warning",
ContextId: "test-ctx",
}

resp, err := check.Validate(t.Context(), valCtx, req)
require.NoError(t, err)
require.Len(t, resp.Results, 1)
require.Equal(t,
azdext.ValidationCheckSeverity_VALIDATION_CHECK_SEVERITY_WARNING,
resp.Results[0].Severity,
)
require.Equal(t,
"This warning was intentional and generated by the demo extension.",
resp.Results[0].Message,
)
})

t.Run("with predicted resources", func(t *testing.T) {
resourcesJSON := []byte(`[{"type": "a"}, {"type": "b"}, {"type": "c"}]`)
valCtx := &azdext.ValidationContext{
ContextID: "test-ctx-2",
CheckType: "local-preflight",
Data: map[string][]byte{
azdext.ValidationContextPredictedResources: resourcesJSON,
},
}
req := &azdext.ValidationCheckRequest{
CheckType: "local-preflight",
RuleId: "demo_warning",
ContextId: "test-ctx-2",
}

resp, err := check.Validate(t.Context(), valCtx, req)
require.NoError(t, err)
require.Len(t, resp.Results, 1)
require.Contains(t, resp.Results[0].Message, "3 predicted resources")
})
}
Loading
Loading