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
14 changes: 14 additions & 0 deletions cli/azd/extensions/azure.provisioning/extension.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# yaml-language-server: $schema=../extension.schema.json
id: azure.provisioning
namespace: provisioning
displayName: Azure.Provisioning (C# CDK)
description: Enables defining Azure infrastructure in C# using Azure.Provisioning instead of Bicep.
usage: azd provisioning <command> [options]
version: 0.1.0
language: go
capabilities:
- importer-provider
providers:
- name: csharp
type: importer
description: Generates Bicep from C# Azure.Provisioning code
48 changes: 48 additions & 0 deletions cli/azd/extensions/azure.provisioning/internal/cmd/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"fmt"

"github.com/azure/azure-dev/cli/azd/extensions/azure.provisioning/internal/project"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/spf13/cobra"
)

func NewRootCommand() *cobra.Command {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Missing standard extension conventions: SilenceUsage: true, SilenceErrors: true, CompletionOptions, hidden help command, --debug/--no-prompt persistent flags, and metadata/version subcommands. See azure.appservice root.go for the pattern.

root := &cobra.Command{
Use: "provisioning",
Short: "Azure.Provisioning C# CDK extension",
}
root.AddCommand(newListenCommand())
return root
}

func newListenCommand() *cobra.Command {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Should be Hidden: true - this is an internal protocol command, not user-facing. Every other extension hides it.

return &cobra.Command{
Use: "listen",
Short: "Starts the extension and listens for events.",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := azdext.WithAccessToken(cmd.Context())

azdClient, err := azdext.NewAzdClient()
if err != nil {
return fmt.Errorf("failed to create azd client: %w", err)
}
defer azdClient.Close()

host := azdext.NewExtensionHost(azdClient).
WithImporter("csharp", func() azdext.ImporterProvider {
return project.NewCSharpImporterProvider(azdClient)
})

if err := host.Run(ctx); err != nil {
return fmt.Errorf("failed to run extension: %w", err)
}

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

package project

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"

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

var _ azdext.ImporterProvider = &CSharpImporterProvider{}

const defaultInfraDir = "infra"

// CSharpImporterProvider generates Bicep infrastructure from C# Azure.Provisioning code.
// It detects .cs files in the infra directory, runs them with `dotnet run`, and captures
// the generated Bicep output.
type CSharpImporterProvider struct {
azdClient *azdext.AzdClient
}

func NewCSharpImporterProvider(azdClient *azdext.AzdClient) azdext.ImporterProvider {
return &CSharpImporterProvider{azdClient: azdClient}
}

// CanImport checks if this importer can handle the given service.
// This importer is infra-only (configured via infra.importer in azure.yaml),
// so it always returns false for service auto-detection.
func (p *CSharpImporterProvider) CanImport(
ctx context.Context,
svcConfig *azdext.ServiceConfig,
) (bool, error) {
return false, nil
}

// Services returns the original service as-is. This importer handles infrastructure
// generation, not service extraction.
func (p *CSharpImporterProvider) Services(
ctx context.Context,
projectConfig *azdext.ProjectConfig,
svcConfig *azdext.ServiceConfig,
) (map[string]*azdext.ServiceConfig, error) {
return map[string]*azdext.ServiceConfig{
svcConfig.Name: svcConfig,
}, nil
}

// ProjectInfrastructure compiles C# Azure.Provisioning code to Bicep for `azd provision`.
func (p *CSharpImporterProvider) ProjectInfrastructure(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] ProjectInfrastructure and GenerateAllInfrastructure duplicate the resolve-compile-read pipeline. Extract a shared helper like compileCSharpToBicep(ctx, projectPath, options) to avoid divergence.

ctx context.Context,
projectPath string,
options map[string]string,
progress azdext.ProgressReporter,
) (*azdext.ImporterProjectInfrastructureResponse, error) {
infraPath := resolvePath(projectPath, options)

progress("Detecting C# infrastructure entry point...")
entryPoint, err := resolveEntryPoint(infraPath)
if err != nil {
return nil, fmt.Errorf("resolving C# entry point: %w", err)
}

// Create temp directory for Bicep output
tempDir, err := os.MkdirTemp("", "azd-csharp-bicep-*")
if err != nil {
return nil, fmt.Errorf("creating temp directory: %w", err)
}
defer os.RemoveAll(tempDir)

progress(fmt.Sprintf("Compiling C# infrastructure from %s...", filepath.Base(entryPoint)))

// Forward importer options (excluding "path") as --key value args to the C# program
extraArgs := optionsToArgs(options)

// Run the C# program
if err := runDotnet(ctx, entryPoint, tempDir, extraArgs); err != nil {
return nil, err
}

// Read generated files
files, err := readGeneratedFiles(tempDir)
if err != nil {
return nil, fmt.Errorf("reading generated Bicep: %w", err)
}

if len(files) == 0 {
return nil, fmt.Errorf(
"no .bicep files generated by %s. Ensure your program calls Build().Save(outputDir) "+
"with the output directory passed as the first argument", entryPoint)
}

progress(fmt.Sprintf("Generated %d Bicep file(s)", len(files)))

return &azdext.ImporterProjectInfrastructureResponse{
InfraOptions: &azdext.InfraOptions{
Provider: "bicep",
Module: "main",
},
Files: files,
}, nil
}

// GenerateAllInfrastructure generates Bicep files for `azd infra gen` (ejection).
func (p *CSharpImporterProvider) GenerateAllInfrastructure(
ctx context.Context,
projectPath string,
options map[string]string,
) ([]*azdext.GeneratedFile, error) {
infraPath := resolvePath(projectPath, options)

entryPoint, err := resolveEntryPoint(infraPath)
if err != nil {
return nil, fmt.Errorf("resolving C# entry point: %w", err)
}

tempDir, err := os.MkdirTemp("", "azd-csharp-bicep-*")
if err != nil {
return nil, fmt.Errorf("creating temp directory: %w", err)
}
defer os.RemoveAll(tempDir)

if err := runDotnet(ctx, entryPoint, tempDir, optionsToArgs(options)); err != nil {
return nil, err
}

files, err := readGeneratedFiles(tempDir)
if err != nil {
return nil, fmt.Errorf("reading generated Bicep: %w", err)
}

// Prefix paths with infra/ for ejection
for _, f := range files {
f.Path = "infra/" + f.Path
}

return files, nil
}

// resolvePath determines the directory containing C# infrastructure files.
func resolvePath(projectPath string, options map[string]string) string {
dir := defaultInfraDir
if v, ok := options["path"]; ok && v != "" {
dir = v
}
return filepath.Join(projectPath, dir)
}

// hasCSharpInfra checks if a directory contains .cs or .csproj files.
func hasCSharpInfra(path string) bool {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Dead code - not called anywhere. The unused linter will flag this. Remove it, or if auto-detection is planned, wire it into CanImport when that's ready.

entries, err := os.ReadDir(path)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext == ".cs" || ext == ".csproj" {
return true
}
}
return false
}

// resolveEntryPoint finds the C# entry point in the given directory.
// Prefers .csproj over single .cs file.
func resolveEntryPoint(infraPath string) (string, error) {
info, err := os.Stat(infraPath)
if err != nil {
return "", fmt.Errorf("path '%s' does not exist: %w", infraPath, err)
}

if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(infraPath))
if ext == ".cs" || ext == ".csproj" {
return infraPath, nil
}
return "", fmt.Errorf("'%s' is not a .cs or .csproj file", infraPath)
}

// Check for .csproj first
csprojFiles, _ := filepath.Glob(filepath.Join(infraPath, "*.csproj"))
if len(csprojFiles) > 0 {
return infraPath, nil
}

// Fall back to single .cs file
csFiles, _ := filepath.Glob(filepath.Join(infraPath, "*.cs"))
if len(csFiles) == 1 {
return csFiles[0], nil
}
if len(csFiles) > 1 {
return "", fmt.Errorf(
"multiple .cs files in '%s' — use a single .cs file or add a .csproj", infraPath)
}

return "", fmt.Errorf("no .cs or .csproj files found in '%s'", infraPath)
}

// optionsToArgs converts the importer options map to --key value CLI args,
// excluding the "path" key which is used for directory resolution.
func optionsToArgs(options map[string]string) []string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Go 1.26 convention: use slices.Sorted(maps.Keys(options)) instead of manual key collection + sort.Strings. Drops the sort import entirely.

// Sort keys for deterministic ordering
keys := make([]string, 0, len(options))
for k := range options {
if k == "path" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)

var args []string
for _, k := range keys {
args = append(args, "--"+k, options[k])
}
return args
}

// runDotnet executes the C# entry point with the output directory as the first argument,
// followed by any extra args from importer options.
func runDotnet(ctx context.Context, entryPoint string, outputDir string, extraArgs []string) error {
var args []string
if strings.HasSuffix(strings.ToLower(entryPoint), ".cs") {
args = []string{"run", entryPoint, "--", outputDir}
} else {
args = []string{"run", "--project", entryPoint, "--", outputDir}
}
args = append(args, extraArgs...)

cmd := exec.CommandContext(ctx, "dotnet", args...)
cmd.Env = append(os.Environ(),
"DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE=1",
"DOTNET_NOLOGO=1",
)

output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("dotnet run failed: %w\nOutput: %s", err, string(output))
}
return nil
}

// readGeneratedFiles reads all .bicep and .json files from a directory.
func readGeneratedFiles(dir string) ([]*azdext.GeneratedFile, error) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] This only reads top-level entries. If the C# program generates nested Bicep modules (e.g., modules/storage.bicep), they're silently dropped and deployment fails with missing module references.

Use filepath.WalkDir to recurse subdirectories and preserve relative paths with filepath.ToSlash.

var files []*azdext.GeneratedFile

entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}

for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext == ".bicep" || ext == ".json" {
content, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, fmt.Errorf("reading %s: %w", e.Name(), err)
}
files = append(files, &azdext.GeneratedFile{
Path: e.Name(),
Content: content,
})
}
}

return files, nil
}
13 changes: 13 additions & 0 deletions cli/azd/extensions/azure.provisioning/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package main

import (
"github.com/azure/azure-dev/cli/azd/extensions/azure.provisioning/internal/cmd"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)

func main() {
azdext.Run(cmd.NewRootCommand())
}