diff --git a/.github/workflows/lint-ext-azure-ai-agents.yml b/.github/workflows/lint-ext-azure-ai-agents.yml index 35d481efa78..af6e35bfa9b 100644 --- a/.github/workflows/lint-ext-azure-ai-agents.yml +++ b/.github/workflows/lint-ext-azure-ai-agents.yml @@ -5,6 +5,7 @@ on: paths: - "cli/azd/extensions/azure.ai.agents/**" - ".github/workflows/lint-ext-azure-ai-agents.yml" + - ".github/workflows/verify-ext-providers.yml" branches: [main] concurrency: @@ -20,3 +21,8 @@ jobs: uses: ./.github/workflows/lint-go.yml with: working-directory: cli/azd/extensions/azure.ai.agents + + verify-providers: + uses: ./.github/workflows/verify-ext-providers.yml + with: + working-directory: cli/azd/extensions/azure.ai.agents diff --git a/.github/workflows/lint-ext-azure-ai-connections.yml b/.github/workflows/lint-ext-azure-ai-connections.yml index a21e047ca4e..9c293f77280 100644 --- a/.github/workflows/lint-ext-azure-ai-connections.yml +++ b/.github/workflows/lint-ext-azure-ai-connections.yml @@ -5,6 +5,7 @@ on: paths: - "cli/azd/extensions/azure.ai.connections/**" - ".github/workflows/lint-ext-azure-ai-connections.yml" + - ".github/workflows/verify-ext-providers.yml" branches: [main] concurrency: @@ -20,3 +21,8 @@ jobs: uses: ./.github/workflows/lint-go.yml with: working-directory: cli/azd/extensions/azure.ai.connections + + verify-providers: + uses: ./.github/workflows/verify-ext-providers.yml + with: + working-directory: cli/azd/extensions/azure.ai.connections diff --git a/.github/workflows/lint-ext-azure-ai-projects.yml b/.github/workflows/lint-ext-azure-ai-projects.yml index c354c6a230f..87421f862b9 100644 --- a/.github/workflows/lint-ext-azure-ai-projects.yml +++ b/.github/workflows/lint-ext-azure-ai-projects.yml @@ -5,6 +5,7 @@ on: paths: - "cli/azd/extensions/azure.ai.projects/**" - ".github/workflows/lint-ext-azure-ai-projects.yml" + - ".github/workflows/verify-ext-providers.yml" branches: [main] concurrency: @@ -20,3 +21,8 @@ jobs: uses: ./.github/workflows/lint-go.yml with: working-directory: cli/azd/extensions/azure.ai.projects + + verify-providers: + uses: ./.github/workflows/verify-ext-providers.yml + with: + working-directory: cli/azd/extensions/azure.ai.projects diff --git a/.github/workflows/lint-ext-azure-ai-routines.yml b/.github/workflows/lint-ext-azure-ai-routines.yml index 5f6f477b509..ddbc7169849 100644 --- a/.github/workflows/lint-ext-azure-ai-routines.yml +++ b/.github/workflows/lint-ext-azure-ai-routines.yml @@ -5,6 +5,7 @@ on: paths: - "cli/azd/extensions/azure.ai.routines/**" - ".github/workflows/lint-ext-azure-ai-routines.yml" + - ".github/workflows/verify-ext-providers.yml" branches: [main] concurrency: @@ -20,3 +21,8 @@ jobs: uses: ./.github/workflows/lint-go.yml with: working-directory: cli/azd/extensions/azure.ai.routines + + verify-providers: + uses: ./.github/workflows/verify-ext-providers.yml + with: + working-directory: cli/azd/extensions/azure.ai.routines diff --git a/.github/workflows/lint-ext-azure-ai-skills.yml b/.github/workflows/lint-ext-azure-ai-skills.yml index 366c5851a55..a20432f65a3 100644 --- a/.github/workflows/lint-ext-azure-ai-skills.yml +++ b/.github/workflows/lint-ext-azure-ai-skills.yml @@ -5,6 +5,7 @@ on: paths: - "cli/azd/extensions/azure.ai.skills/**" - ".github/workflows/lint-ext-azure-ai-skills.yml" + - ".github/workflows/verify-ext-providers.yml" branches: [main] concurrency: @@ -20,3 +21,8 @@ jobs: uses: ./.github/workflows/lint-go.yml with: working-directory: cli/azd/extensions/azure.ai.skills + + verify-providers: + uses: ./.github/workflows/verify-ext-providers.yml + with: + working-directory: cli/azd/extensions/azure.ai.skills diff --git a/.github/workflows/lint-ext-azure-ai-toolboxes.yml b/.github/workflows/lint-ext-azure-ai-toolboxes.yml index a03e62c6f69..381da16aa6c 100644 --- a/.github/workflows/lint-ext-azure-ai-toolboxes.yml +++ b/.github/workflows/lint-ext-azure-ai-toolboxes.yml @@ -5,6 +5,7 @@ on: paths: - "cli/azd/extensions/azure.ai.toolboxes/**" - ".github/workflows/lint-ext-azure-ai-toolboxes.yml" + - ".github/workflows/verify-ext-providers.yml" branches: [main] concurrency: @@ -20,3 +21,8 @@ jobs: uses: ./.github/workflows/lint-go.yml with: working-directory: cli/azd/extensions/azure.ai.toolboxes + + verify-providers: + uses: ./.github/workflows/verify-ext-providers.yml + with: + working-directory: cli/azd/extensions/azure.ai.toolboxes diff --git a/.github/workflows/verify-ext-providers.yml b/.github/workflows/verify-ext-providers.yml new file mode 100644 index 00000000000..7b2a37768b8 --- /dev/null +++ b/.github/workflows/verify-ext-providers.yml @@ -0,0 +1,69 @@ +name: verify-ext-providers + +on: + pull_request: + paths: + - "cli/azd/extensions/*/extension.yaml" + - "cli/azd/extensions/extension.schema.json" + - "cli/azd/extensions/registry.schema.json" + - "cli/azd/pkg/extensions/registry.go" + - "cli/azd/pkg/extensions/validate_registry.go" + - "cli/azd/pkg/extensions/capability_schema_test.go" + - "cli/azd/pkg/extensions/extension_manifest_files_test.go" + - ".github/workflows/verify-ext-providers.yml" + branches: [main] + workflow_call: + inputs: + working-directory: + description: "Path to the extension module directory" + required: true + type: string + go-version-file: + description: "Path to go.mod to read Go version from" + required: false + type: string + default: "cli/azd/go.mod" + +permissions: + contents: read + +jobs: + verify-coverage: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli/azd + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: cli/azd/go.mod + - name: Verify provider contract coverage + run: | + go test ./pkg/extensions \ + -run '^(TestExtensionManifestsHaveProviderVerificationTests|TestCapabilitySchemaTypesInSyncWithGo)$' -v + + verify-providers: + if: github.event_name == 'workflow_call' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: ${{ inputs.go-version-file }} + # go test -run exits 0 when nothing matches, so first require the + # canonical test file and exact test name. + - name: Verify providers match manifest + working-directory: ${{ inputs.working-directory }} + run: | + if [ ! -f internal/cmd/providers_manifest_test.go ]; then + echo "::error::internal/cmd/providers_manifest_test.go not found." + exit 1 + fi + if ! go test ./internal/cmd/ -list '^TestConfigureExtensionHostMatchesManifest$' \ + | grep -qx 'TestConfigureExtensionHostMatchesManifest'; then + echo "::error::TestConfigureExtensionHostMatchesManifest not found in ./internal/cmd/." + exit 1 + fi + go test ./internal/cmd/ -run '^TestConfigureExtensionHostMatchesManifest$' -v diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index a1fc7a392f5..6d5b3b361c4 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -12,7 +12,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.3.0-beta.3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 - github.com/azure/azure-dev/cli/azd v1.27.1 + github.com/azure/azure-dev/cli/azd v1.27.2 github.com/braydonk/yaml v0.9.0 github.com/drone/envsubst v1.0.3 github.com/fatih/color v1.18.0 diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/providers_manifest_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/providers_manifest_test.go new file mode 100644 index 00000000000..2588ad49b9c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/providers_manifest_test.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestConfigureExtensionHostMatchesManifest verifies that the providers this +// extension registers match those declared in its extension.yaml. +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + manifestPath := filepath.Join("..", "..", "extension.yaml") + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, manifestPath)) +} diff --git a/cli/azd/extensions/azure.ai.connections/go.mod b/cli/azd/extensions/azure.ai.connections/go.mod index 26fffda7718..01527bbd67c 100644 --- a/cli/azd/extensions/azure.ai.connections/go.mod +++ b/cli/azd/extensions/azure.ai.connections/go.mod @@ -6,7 +6,7 @@ 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/cognitiveservices/armcognitiveservices/v2 v2.0.0 - github.com/azure/azure-dev/cli/azd v1.27.1 + github.com/azure/azure-dev/cli/azd v1.27.2 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/providers_manifest_test.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/providers_manifest_test.go new file mode 100644 index 00000000000..2588ad49b9c --- /dev/null +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/providers_manifest_test.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestConfigureExtensionHostMatchesManifest verifies that the providers this +// extension registers match those declared in its extension.yaml. +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + manifestPath := filepath.Join("..", "..", "extension.yaml") + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, manifestPath)) +} diff --git a/cli/azd/extensions/azure.ai.projects/go.mod b/cli/azd/extensions/azure.ai.projects/go.mod index 770b267b855..6b3307a3261 100644 --- a/cli/azd/extensions/azure.ai.projects/go.mod +++ b/cli/azd/extensions/azure.ai.projects/go.mod @@ -4,7 +4,7 @@ go 1.26.4 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 - github.com/azure/azure-dev/cli/azd v1.25.0 + github.com/azure/azure-dev/cli/azd v1.27.2 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/providers_manifest_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/providers_manifest_test.go new file mode 100644 index 00000000000..2588ad49b9c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/providers_manifest_test.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestConfigureExtensionHostMatchesManifest verifies that the providers this +// extension registers match those declared in its extension.yaml. +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + manifestPath := filepath.Join("..", "..", "extension.yaml") + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, manifestPath)) +} diff --git a/cli/azd/extensions/azure.ai.routines/go.mod b/cli/azd/extensions/azure.ai.routines/go.mod index bab75ff742b..e24227d4930 100644 --- a/cli/azd/extensions/azure.ai.routines/go.mod +++ b/cli/azd/extensions/azure.ai.routines/go.mod @@ -5,7 +5,7 @@ go 1.26.4 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-dev/cli/azd v1.27.1 + github.com/azure/azure-dev/cli/azd v1.27.2 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/providers_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/providers_manifest_test.go new file mode 100644 index 00000000000..2588ad49b9c --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/providers_manifest_test.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestConfigureExtensionHostMatchesManifest verifies that the providers this +// extension registers match those declared in its extension.yaml. +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + manifestPath := filepath.Join("..", "..", "extension.yaml") + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, manifestPath)) +} diff --git a/cli/azd/extensions/azure.ai.skills/go.mod b/cli/azd/extensions/azure.ai.skills/go.mod index 128a21a42c7..67230ebc872 100644 --- a/cli/azd/extensions/azure.ai.skills/go.mod +++ b/cli/azd/extensions/azure.ai.skills/go.mod @@ -5,7 +5,7 @@ go 1.26.4 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3 - github.com/azure/azure-dev/cli/azd v1.25.0 + github.com/azure/azure-dev/cli/azd v1.27.2 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/providers_manifest_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/providers_manifest_test.go new file mode 100644 index 00000000000..2588ad49b9c --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/providers_manifest_test.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestConfigureExtensionHostMatchesManifest verifies that the providers this +// extension registers match those declared in its extension.yaml. +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + manifestPath := filepath.Join("..", "..", "extension.yaml") + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, manifestPath)) +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/go.mod b/cli/azd/extensions/azure.ai.toolboxes/go.mod index c6acba62c1a..62f68b58b0e 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/go.mod +++ b/cli/azd/extensions/azure.ai.toolboxes/go.mod @@ -5,7 +5,7 @@ go 1.26.4 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-dev/cli/azd v1.27.1 + github.com/azure/azure-dev/cli/azd v1.27.2 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 google.golang.org/grpc v1.80.0 diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/providers_manifest_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/providers_manifest_test.go new file mode 100644 index 00000000000..2588ad49b9c --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/providers_manifest_test.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestConfigureExtensionHostMatchesManifest verifies that the providers this +// extension registers match those declared in its extension.yaml. +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + manifestPath := filepath.Join("..", "..", "extension.yaml") + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, manifestPath)) +} diff --git a/cli/azd/extensions/extension.schema.json b/cli/azd/extensions/extension.schema.json index 97784f836f5..2a8340e59ff 100644 --- a/cli/azd/extensions/extension.schema.json +++ b/cli/azd/extensions/extension.schema.json @@ -112,7 +112,7 @@ "capabilities": { "type": "array", "title": "Capabilities", - "description": "List of capabilities provided by the extension. Supported values: custom-commands, lifecycle-events, mcp-server, service-target-provider, framework-service-provider, provisioning-provider, metadata. Select one or more from the allowed list. Each value must be unique. Not required for extension packs, which declare dependencies instead and have no executable.", + "description": "List of capabilities provided by the extension. Supported values: custom-commands, lifecycle-events, mcp-server, service-target-provider, framework-service-provider, provisioning-provider, validation-provider, metadata. Select one or more from the allowed list. Each value must be unique. Not required for extension packs, which declare dependencies instead and have no executable.", "minItems": 1, "uniqueItems": true, "items": { @@ -153,6 +153,12 @@ "title": "Provisioning Provider", "description": "Provisioning provider enables extensions to provide a custom infrastructure provisioning experience." }, + { + "type": "string", + "const": "validation-provider", + "title": "Validation Provider", + "description": "Validation provider enables extensions to contribute checks to azd validation pipelines." + }, { "type": "string", "const": "metadata", diff --git a/cli/azd/extensions/microsoft.azd.demo/ci-test.ps1 b/cli/azd/extensions/microsoft.azd.demo/ci-test.ps1 index db87a8a2ceb..5042111638d 100644 --- a/cli/azd/extensions/microsoft.azd.demo/ci-test.ps1 +++ b/cli/azd/extensions/microsoft.azd.demo/ci-test.ps1 @@ -1,4 +1,8 @@ -Write-Host "Run test command(s) here..." - -# Exit 0 for success, nonzero exit for failure -exit 0 +Write-Host "Running unit tests..." +go test ./... -count=1 + +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +exit 0 diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go index 8b476eee273..dad328203e1 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go @@ -28,77 +28,8 @@ func newListenCommand() *cobra.Command { } defer azdClient.Close() - host := azdext.NewExtensionHost(azdClient). - WithServiceTarget("demo", func() azdext.ServiceTargetProvider { - return project.NewDemoServiceTargetProvider(azdClient) - }). - WithFrameworkService("rust", func() azdext.FrameworkServiceProvider { - return project.NewDemoFrameworkServiceProvider(azdClient) - }). - WithProvisioningProvider("demo", func() azdext.ProvisioningProvider { - return project.NewDemoProvisioningProvider(azdClient) - }). - WithValidationCheck(azdext.ValidationCheckRegistration{ - // Bicep-only check: runs during BicepProvider preflight and - // receives the Bicep snapshot / ARM template context. It is - // skipped gracefully when no snapshot is available (e.g. a - // non-Bicep provider), but is not dead code for Bicep. - CheckType: azdext.ValidationCheckTypeLocalPreflight, - RuleID: "demo_warning", - Factory: func() azdext.ValidationCheckProvider { - return project.NewDemoValidationCheck() - }, - }). - WithValidationCheck(azdext.ValidationCheckRegistration{ - // Provider-agnostic check: runs before provisioning for every - // provider (Bicep, Terraform, and extension providers such as - // this demo provider). Receives the lean provision context. - CheckType: azdext.ValidationCheckTypeProvision, - RuleID: "demo_provision_warning", - Factory: func() azdext.ValidationCheckProvider { - return project.NewDemoProvisionValidationCheck() - }, - }). - 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) - time.Sleep(250 * time.Millisecond) - } - - return nil - }). - WithProjectEventHandler("predeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { - for i := 1; i <= 20; i++ { - fmt.Printf("%d. Doing important predeploy project work in extension...\n", i) - time.Sleep(250 * time.Millisecond) - } - - return nil - }). - WithProjectEventHandler("postdeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { - for i := 1; i <= 20; i++ { - fmt.Printf("%d. Doing important postdeploy project work in extension...\n", i) - time.Sleep(250 * time.Millisecond) - } - - return nil - }). - WithServiceEventHandler("prepackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { - for i := 1; i <= 20; i++ { - fmt.Printf("Service: %s, Artifacts: %d\n", args.Service.Name, len(args.ServiceContext.Package)) - time.Sleep(250 * time.Millisecond) - } - - return nil - }, nil). - WithServiceEventHandler("postpackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { - for i := 1; i <= 20; i++ { - fmt.Printf("Service: %s, Artifacts: %d\n", args.Service.Name, len(args.ServiceContext.Package)) - time.Sleep(250 * time.Millisecond) - } - - return nil - }, nil) + host := azdext.NewExtensionHost(azdClient) + configureExtensionHost(host) // Start listening for events // This is a blocking call and will not return until the server connection is closed. @@ -112,3 +43,81 @@ func newListenCommand() *cobra.Command { return cmd } + +// configureExtensionHost wires the demo extension's providers and event handlers onto +// the supplied host, so tests can verify the registrations against extension.yaml. +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + + host. + WithServiceTarget("demo", func() azdext.ServiceTargetProvider { + return project.NewDemoServiceTargetProvider(azdClient) + }). + WithFrameworkService("rust", func() azdext.FrameworkServiceProvider { + return project.NewDemoFrameworkServiceProvider(azdClient) + }). + WithProvisioningProvider("demo", func() azdext.ProvisioningProvider { + return project.NewDemoProvisioningProvider(azdClient) + }). + WithValidationCheck(azdext.ValidationCheckRegistration{ + // Bicep-only check: runs during BicepProvider preflight and + // receives the Bicep snapshot / ARM template context. It is + // skipped gracefully when no snapshot is available (e.g. a + // non-Bicep provider), but is not dead code for Bicep. + CheckType: azdext.ValidationCheckTypeLocalPreflight, + RuleID: "demo_warning", + Factory: func() azdext.ValidationCheckProvider { + return project.NewDemoValidationCheck() + }, + }). + WithValidationCheck(azdext.ValidationCheckRegistration{ + // Provider-agnostic check: runs before provisioning for every + // provider (Bicep, Terraform, and extension providers such as + // this demo provider). Receives the lean provision context. + CheckType: azdext.ValidationCheckTypeProvision, + RuleID: "demo_provision_warning", + Factory: func() azdext.ValidationCheckProvider { + return project.NewDemoProvisionValidationCheck() + }, + }). + 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) + time.Sleep(250 * time.Millisecond) + } + + return nil + }). + WithProjectEventHandler("predeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + for i := 1; i <= 20; i++ { + fmt.Printf("%d. Doing important predeploy project work in extension...\n", i) + time.Sleep(250 * time.Millisecond) + } + + return nil + }). + WithProjectEventHandler("postdeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + for i := 1; i <= 20; i++ { + fmt.Printf("%d. Doing important postdeploy project work in extension...\n", i) + time.Sleep(250 * time.Millisecond) + } + + return nil + }). + WithServiceEventHandler("prepackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { + for i := 1; i <= 20; i++ { + fmt.Printf("Service: %s, Artifacts: %d\n", args.Service.Name, len(args.ServiceContext.Package)) + time.Sleep(250 * time.Millisecond) + } + + return nil + }, nil). + WithServiceEventHandler("postpackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { + for i := 1; i <= 20; i++ { + fmt.Printf("Service: %s, Artifacts: %d\n", args.Service.Name, len(args.ServiceContext.Package)) + time.Sleep(250 * time.Millisecond) + } + + return nil + }, nil) +} diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/providers_manifest_test.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/providers_manifest_test.go new file mode 100644 index 00000000000..2588ad49b9c --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/providers_manifest_test.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestConfigureExtensionHostMatchesManifest verifies that the providers this +// extension registers match those declared in its extension.yaml. +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + manifestPath := filepath.Join("..", "..", "extension.yaml") + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, manifestPath)) +} diff --git a/cli/azd/extensions/registry.schema.json b/cli/azd/extensions/registry.schema.json index 464834349e6..5ab7d22be42 100644 --- a/cli/azd/extensions/registry.schema.json +++ b/cli/azd/extensions/registry.schema.json @@ -81,6 +81,7 @@ "service-target-provider", "framework-service-provider", "provisioning-provider", + "validation-provider", "metadata" ] } diff --git a/cli/azd/pkg/azdext/extension_host.go b/cli/azd/pkg/azdext/extension_host.go index 760469bdb63..8c234abdedf 100644 --- a/cli/azd/pkg/azdext/extension_host.go +++ b/cli/azd/pkg/azdext/extension_host.go @@ -9,6 +9,7 @@ import ( "fmt" "log" "os" + "slices" "strconv" "sync" @@ -127,6 +128,18 @@ func (er *ExtensionHost) Client() *AzdClient { return er.client } +// ServiceTargets returns a copy of the service target providers registered so far, +// letting tests and tooling introspect registrations without invoking Run. +func (er *ExtensionHost) ServiceTargets() []ServiceTargetRegistration { + return slices.Clone(er.serviceTargets) +} + +// ProvisioningProviders returns a copy of the provisioning providers registered so +// far. See [ExtensionHost.ServiceTargets]. +func (er *ExtensionHost) ProvisioningProviders() []ProvisioningProviderRegistration { + return slices.Clone(er.provisioningProviders) +} + func (er *ExtensionHost) initManagers(extensionId string, brokerLogger *log.Logger) { if er.serviceTargetManager == nil { er.serviceTargetManager = NewServiceTargetManager(extensionId, er.client, brokerLogger) diff --git a/cli/azd/pkg/azdext/provider_manifest.go b/cli/azd/pkg/azdext/provider_manifest.go new file mode 100644 index 00000000000..e4244fa7b89 --- /dev/null +++ b/cli/azd/pkg/azdext/provider_manifest.go @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "fmt" + "os" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "gopkg.in/yaml.v3" +) + +// manifestProviderDoc is the minimal subset of an extension.yaml manifest needed to +// compare declared providers against what an extension registers at runtime. +type manifestProviderDoc struct { + Providers []extensions.Provider `yaml:"providers"` +} + +// manifestComparedProviderTypes is the set of provider types representable in a +// manifest's providers: list. Framework service and validation providers are +// registered in code only, so they are excluded. +var manifestComparedProviderTypes = []extensions.ProviderType{ + extensions.ServiceTargetProviderType, + extensions.ProvisioningProviderType, +} + +// VerifyProvidersMatchManifest asserts that the providers an extension registers via +// the supplied configure callback exactly match the providers declared in its +// extension.yaml manifest at manifestPath. +// +// It runs configure against a bare [ExtensionHost] (no azd connection; provider +// factories are never invoked) and compares the registered names against the +// manifest's providers: list. Only service-target and provisioning-provider types +// are compared; framework-service and validation registrations have no manifest +// representation. +// +// It returns a descriptive error when a provider is declared but not registered, +// registered but not declared, or listed more than once on either side. +func VerifyProvidersMatchManifest(configure func(host *ExtensionHost), manifestPath string) error { + if configure == nil { + return fmt.Errorf("configure callback must not be nil") + } + + declared, err := loadManifestProviders(manifestPath) + if err != nil { + return err + } + + host := NewExtensionHost(nil) + configure(host) + + registered := map[extensions.ProviderType][]string{} + for _, reg := range host.ServiceTargets() { + registered[extensions.ServiceTargetProviderType] = append( + registered[extensions.ServiceTargetProviderType], reg.Host) + } + for _, reg := range host.ProvisioningProviders() { + registered[extensions.ProvisioningProviderType] = append( + registered[extensions.ProvisioningProviderType], reg.Name) + } + + var mismatches []string + for _, providerType := range manifestComparedProviderTypes { + declaredNames := declared[providerType] + registeredNames := registered[providerType] + + for _, name := range duplicatedNames(declaredNames) { + mismatches = append(mismatches, fmt.Sprintf( + "provider %q of type %q is declared more than once in %s", + name, providerType, manifestPath)) + } + for _, name := range duplicatedNames(registeredNames) { + mismatches = append(mismatches, fmt.Sprintf( + "provider %q of type %q is registered more than once by the extension", + name, providerType)) + } + + for _, name := range declaredNames { + if !slices.Contains(registeredNames, name) { + mismatches = append(mismatches, fmt.Sprintf( + "provider %q of type %q is declared in %s but not registered by the extension", + name, providerType, manifestPath)) + } + } + for _, name := range registeredNames { + if !slices.Contains(declaredNames, name) { + mismatches = append(mismatches, fmt.Sprintf( + "provider %q of type %q is registered by the extension but not declared in %s", + name, providerType, manifestPath)) + } + } + } + + if len(mismatches) > 0 { + slices.Sort(mismatches) + return fmt.Errorf("extension providers do not match manifest:\n - %s", + strings.Join(mismatches, "\n - ")) + } + + return nil +} + +// duplicatedNames returns, once each and sorted, the names that appear more than +// once in names. Matching is case-insensitive because differently cased provider +// names collide during registry discovery, but the first-seen spelling is reported. +func duplicatedNames(names []string) []string { + counts := make(map[string]int, len(names)) + original := make(map[string]string, len(names)) + for _, name := range names { + key := strings.ToLower(name) + counts[key]++ + if _, ok := original[key]; !ok { + original[key] = name + } + } + + var duplicated []string + for key, count := range counts { + if count > 1 { + duplicated = append(duplicated, original[key]) + } + } + slices.Sort(duplicated) + return duplicated +} + +// loadManifestProviders reads and groups a manifest's declared providers by type, +// limited to the types that are comparable against runtime registrations. +func loadManifestProviders(manifestPath string) (map[extensions.ProviderType][]string, error) { + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("reading manifest %s: %w", manifestPath, err) + } + + var doc manifestProviderDoc + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parsing manifest %s: %w", manifestPath, err) + } + + declared := map[extensions.ProviderType][]string{} + for _, provider := range doc.Providers { + if slices.Contains(manifestComparedProviderTypes, provider.Type) { + declared[provider.Type] = append(declared[provider.Type], provider.Name) + } + } + return declared, nil +} diff --git a/cli/azd/pkg/azdext/provider_manifest_test.go b/cli/azd/pkg/azdext/provider_manifest_test.go new file mode 100644 index 00000000000..9b8872f9201 --- /dev/null +++ b/cli/azd/pkg/azdext/provider_manifest_test.go @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeManifest(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "extension.yaml") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func TestVerifyProvidersMatchManifest_Match(t *testing.T) { + manifest := writeManifest(t, ` +id: publisher.extension +providers: + - name: custom.host + type: service-target + description: d + - name: custom.provider + type: provisioning-provider + description: d +`) + + configure := func(host *ExtensionHost) { + host. + WithServiceTarget("custom.host", func() ServiceTargetProvider { return nil }). + WithProvisioningProvider("custom.provider", func() ProvisioningProvider { return nil }) + } + + require.NoError(t, VerifyProvidersMatchManifest(configure, manifest)) +} + +func TestVerifyProvidersMatchManifest_DeadClaim(t *testing.T) { + // Manifest declares a service target the code never registers. + manifest := writeManifest(t, ` +id: publisher.extension +providers: + - name: custom.host + type: service-target + description: d +`) + + configure := func(host *ExtensionHost) {} + + err := VerifyProvidersMatchManifest(configure, manifest) + require.Error(t, err) + require.Contains(t, err.Error(), "declared in") + require.Contains(t, err.Error(), "not registered") +} + +func TestVerifyProvidersMatchManifest_UndeclaredRegistration(t *testing.T) { + // Code registers a provider the manifest does not declare. + manifest := writeManifest(t, ` +id: publisher.extension +providers: [] +`) + + configure := func(host *ExtensionHost) { + host.WithServiceTarget("custom.host", func() ServiceTargetProvider { return nil }) + } + + err := VerifyProvidersMatchManifest(configure, manifest) + require.Error(t, err) + require.Contains(t, err.Error(), "registered by the extension but not declared") +} + +func TestVerifyProvidersMatchManifest_IgnoresFrameworkAndValidation(t *testing.T) { + // Framework and validation registrations are not represented in providers: and + // must not cause a mismatch. + manifest := writeManifest(t, ` +id: publisher.extension +providers: + - name: custom.host + type: service-target + description: d +`) + + configure := func(host *ExtensionHost) { + host. + WithServiceTarget("custom.host", func() ServiceTargetProvider { return nil }). + WithFrameworkService("rust", func() FrameworkServiceProvider { return nil }) + } + + require.NoError(t, VerifyProvidersMatchManifest(configure, manifest)) +} + +func TestVerifyProvidersMatchManifest_DuplicateRegistration(t *testing.T) { + // A duplicate registration is rejected at runtime, so the check must flag it. + manifest := writeManifest(t, ` +id: publisher.extension +providers: + - name: custom.host + type: service-target + description: d +`) + + configure := func(host *ExtensionHost) { + host. + WithServiceTarget("custom.host", func() ServiceTargetProvider { return nil }). + WithServiceTarget("custom.host", func() ServiceTargetProvider { return nil }) + } + + err := VerifyProvidersMatchManifest(configure, manifest) + require.Error(t, err) + require.Contains(t, err.Error(), "registered more than once") +} + +func TestVerifyProvidersMatchManifest_DuplicateManifestEntry(t *testing.T) { + manifest := writeManifest(t, ` +id: publisher.extension +providers: + - name: custom.host + type: service-target + description: d + - name: custom.host + type: service-target + description: d +`) + + configure := func(host *ExtensionHost) { + host.WithServiceTarget("custom.host", func() ServiceTargetProvider { return nil }) + } + + err := VerifyProvidersMatchManifest(configure, manifest) + require.Error(t, err) + require.Contains(t, err.Error(), "declared more than once") +} + +func TestVerifyProvidersMatchManifest_NilConfigure(t *testing.T) { + manifest := writeManifest(t, "id: publisher.extension\n") + require.Error(t, VerifyProvidersMatchManifest(nil, manifest)) +} + +func TestVerifyProvidersMatchManifest_CaseMismatch(t *testing.T) { + // Runtime provider registration uses exact string keys, so a casing-only + // difference between the manifest and code must be reported. + manifest := writeManifest(t, ` +id: publisher.extension +providers: + - name: Custom.Host + type: service-target + description: d +`) + + configure := func(host *ExtensionHost) { + host.WithServiceTarget("custom.host", func() ServiceTargetProvider { return nil }) + } + + err := VerifyProvidersMatchManifest(configure, manifest) + require.Error(t, err) + require.Contains(t, err.Error(), "declared in") + require.Contains(t, err.Error(), "not registered") + require.Contains(t, err.Error(), "registered by the extension but not declared") +} + +func TestVerifyProvidersMatchManifest_CaseInsensitiveDuplicate(t *testing.T) { + // Duplicate detection is case-insensitive, matching core. + manifest := writeManifest(t, ` +id: publisher.extension +providers: + - name: Custom.Host + type: service-target + description: d + - name: custom.host + type: service-target + description: d +`) + + configure := func(host *ExtensionHost) { + host.WithServiceTarget("custom.host", func() ServiceTargetProvider { return nil }) + } + + err := VerifyProvidersMatchManifest(configure, manifest) + require.Error(t, err) + require.Contains(t, err.Error(), "declared more than once") +} diff --git a/cli/azd/pkg/extensions/capability_schema_test.go b/cli/azd/pkg/extensions/capability_schema_test.go new file mode 100644 index 00000000000..4597d1f35de --- /dev/null +++ b/cli/azd/pkg/extensions/capability_schema_test.go @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestCapabilitySchemaTypesInSyncWithGo keeps both JSON schema capability enums +// aligned with the capabilities accepted by azd. +func TestCapabilitySchemaTypesInSyncWithGo(t *testing.T) { + expected := capabilityStrings() + + t.Run("extension.schema.json", func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "extensions", "extension.schema.json")) + require.NoError(t, err) + + var schema struct { + Properties struct { + Capabilities struct { + Items struct { + OneOf []struct { + Const string `json:"const"` + } `json:"oneOf"` + } `json:"items"` + } `json:"capabilities"` + } `json:"properties"` + } + require.NoError(t, json.Unmarshal(data, &schema)) + + actual := make([]string, 0, len(schema.Properties.Capabilities.Items.OneOf)) + for _, capability := range schema.Properties.Capabilities.Items.OneOf { + actual = append(actual, capability.Const) + } + require.ElementsMatch(t, expected, actual) + }) + + t.Run("registry.schema.json", func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "extensions", "registry.schema.json")) + require.NoError(t, err) + + var schema struct { + Definitions struct { + Version struct { + Properties struct { + Capabilities struct { + Items struct { + Enum []string `json:"enum"` + } `json:"items"` + } `json:"capabilities"` + } `json:"properties"` + } `json:"Version"` + } `json:"definitions"` + } + require.NoError(t, json.Unmarshal(data, &schema)) + + require.ElementsMatch(t, expected, schema.Definitions.Version.Properties.Capabilities.Items.Enum) + }) +} diff --git a/cli/azd/pkg/extensions/extension_manifest_files_test.go b/cli/azd/pkg/extensions/extension_manifest_files_test.go new file mode 100644 index 00000000000..f96b2df7c4c --- /dev/null +++ b/cli/azd/pkg/extensions/extension_manifest_files_test.go @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +const providerVerificationTestName = "TestConfigureExtensionHostMatchesManifest" + +type manifestProviders struct { + ID string `yaml:"id"` + Providers []Provider `yaml:"providers"` + path string +} + +func loadFirstPartyManifests(t *testing.T) []manifestProviders { + t.Helper() + + matches, err := filepath.Glob(filepath.Join("..", "..", "extensions", "*", "extension.yaml")) + require.NoError(t, err) + require.NotEmpty(t, matches, "expected to find first-party extension manifests") + + manifests := make([]manifestProviders, 0, len(matches)) + for _, path := range matches { + data, err := os.ReadFile(path) + require.NoErrorf(t, err, "reading %s", path) + + var manifest manifestProviders + require.NoErrorf(t, yaml.Unmarshal(data, &manifest), "parsing %s", path) + require.NotEmptyf(t, manifest.ID, "manifest %s is missing the required 'id' field", path) + manifest.path = path + manifests = append(manifests, manifest) + } + + return manifests +} + +// TestExtensionManifestsHaveProviderVerificationTests ensures every first-party +// provider extension carries the canonical runtime registration verification test. +func TestExtensionManifestsHaveProviderVerificationTests(t *testing.T) { + for _, manifest := range loadFirstPartyManifests(t) { + t.Run(manifest.ID, func(t *testing.T) { + if len(manifest.Providers) == 0 { + return + } + + testPath := filepath.Join( + filepath.Dir(manifest.path), + "internal", + "cmd", + "providers_manifest_test.go", + ) + require.Truef(t, hasCanonicalProviderVerificationTest(t, testPath), + "extension %s declares providers in %s but %s does not define %s asserting "+ + "azdext.VerifyProvidersMatchManifest with require.NoError", + manifest.ID, manifest.path, testPath, providerVerificationTestName) + }) + } +} + +func hasCanonicalProviderVerificationTest(t *testing.T, testPath string) bool { + t.Helper() + + file, err := parser.ParseFile(token.NewFileSet(), testPath, nil, 0) + if os.IsNotExist(err) { + return false + } + require.NoError(t, err) + + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name.Name != providerVerificationTestName || function.Body == nil { + continue + } + + found := false + ast.Inspect(function.Body, func(node ast.Node) bool { + if found { + return false + } + + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if isAssertedProviderVerificationCall(call) { + found = true + return false + } + return true + }) + return found + } + + return false +} + +func isAssertedProviderVerificationCall(call *ast.CallExpr) bool { + requireCall, ok := call.Fun.(*ast.SelectorExpr) + if !ok || requireCall.Sel.Name != "NoError" || len(call.Args) < 2 { + return false + } + requirePackage, ok := requireCall.X.(*ast.Ident) + if !ok || requirePackage.Name != "require" { + return false + } + + verificationCall, ok := call.Args[1].(*ast.CallExpr) + if !ok { + return false + } + verificationFunction, ok := verificationCall.Fun.(*ast.SelectorExpr) + if !ok || verificationFunction.Sel.Name != "VerifyProvidersMatchManifest" { + return false + } + azdextPackage, ok := verificationFunction.X.(*ast.Ident) + return ok && azdextPackage.Name == "azdext" +} + +func TestHasCanonicalProviderVerificationTestRequiresAssertedError(t *testing.T) { + tests := []struct { + name string + source string + want bool + }{ + { + name: "asserted error", + source: `package cmd +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, "extension.yaml")) +}`, + want: true, + }, + { + name: "asserted error followed by another call", + source: `package cmd +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, "extension.yaml")) + require.True(t, true) +}`, + want: true, + }, + { + name: "discarded error", + source: `package cmd +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + _ = azdext.VerifyProvidersMatchManifest(configureExtensionHost, "extension.yaml") +}`, + }, + { + name: "comment only", + source: `package cmd +func TestConfigureExtensionHostMatchesManifest(t *testing.T) { + // require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, "extension.yaml")) +}`, + }, + { + name: "different test", + source: `package cmd +func TestConfigureExtensionHostMatchesManifest(t *testing.T) {} +func TestOther(t *testing.T) { + require.NoError(t, azdext.VerifyProvidersMatchManifest(configureExtensionHost, "extension.yaml")) +}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + testPath := filepath.Join(t.TempDir(), "providers_manifest_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(test.source), 0o600)) + require.Equal(t, test.want, hasCanonicalProviderVerificationTest(t, testPath)) + }) + } +} diff --git a/cli/azd/pkg/extensions/registry.go b/cli/azd/pkg/extensions/registry.go index 6748994b77f..dd46b919a27 100644 --- a/cli/azd/pkg/extensions/registry.go +++ b/cli/azd/pkg/extensions/registry.go @@ -64,6 +64,8 @@ type ProviderType string const ( // Service target provider type for custom deployment targets ServiceTargetProviderType ProviderType = "service-target" + // Provisioning provider type for custom infrastructure provisioning experiences + ProvisioningProviderType ProviderType = "provisioning-provider" ) // Extension represents an extension in the registry diff --git a/cli/azd/pkg/extensions/validate_registry_test.go b/cli/azd/pkg/extensions/validate_registry_test.go index f19ff61f908..f264b298b4b 100644 --- a/cli/azd/pkg/extensions/validate_registry_test.go +++ b/cli/azd/pkg/extensions/validate_registry_test.go @@ -549,6 +549,7 @@ func TestValidateExtension_AllValidCapabilities(t *testing.T) { FrameworkServiceProviderCapability, MetadataCapability, ProvisioningProviderCapability, + ValidationProviderCapability, }, Artifacts: validArtifacts(), },