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 {
root := &cobra.Command{
Use: "provisioning",
Short: "Azure.Provisioning C# CDK extension",
}
root.AddCommand(newListenCommand())
return root
}

func newListenCommand() *cobra.Command {
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(
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 {
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 {
// 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) {
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())
}