diff --git a/cli/azd/extensions/azure.provisioning/extension.yaml b/cli/azd/extensions/azure.provisioning/extension.yaml new file mode 100644 index 00000000000..2f33786593a --- /dev/null +++ b/cli/azd/extensions/azure.provisioning/extension.yaml @@ -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 [options] +version: 0.1.0 +language: go +capabilities: + - importer-provider +providers: + - name: csharp + type: importer + description: Generates Bicep from C# Azure.Provisioning code diff --git a/cli/azd/extensions/azure.provisioning/internal/cmd/commands.go b/cli/azd/extensions/azure.provisioning/internal/cmd/commands.go new file mode 100644 index 00000000000..ed775b7dcd5 --- /dev/null +++ b/cli/azd/extensions/azure.provisioning/internal/cmd/commands.go @@ -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 + }, + } +} diff --git a/cli/azd/extensions/azure.provisioning/internal/project/importer_csharp.go b/cli/azd/extensions/azure.provisioning/internal/project/importer_csharp.go new file mode 100644 index 00000000000..8cd447c8342 --- /dev/null +++ b/cli/azd/extensions/azure.provisioning/internal/project/importer_csharp.go @@ -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 +} diff --git a/cli/azd/extensions/azure.provisioning/main.go b/cli/azd/extensions/azure.provisioning/main.go new file mode 100644 index 00000000000..2604791b165 --- /dev/null +++ b/cli/azd/extensions/azure.provisioning/main.go @@ -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()) +}